@atproto/pds 0.4.34 → 0.4.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/account-manager/db/migrations/004-oauth.d.ts +4 -0
  3. package/dist/account-manager/db/migrations/004-oauth.d.ts.map +1 -0
  4. package/dist/account-manager/db/migrations/004-oauth.js +106 -0
  5. package/dist/account-manager/db/migrations/004-oauth.js.map +1 -0
  6. package/dist/account-manager/db/migrations/index.d.ts +2 -0
  7. package/dist/account-manager/db/migrations/index.d.ts.map +1 -1
  8. package/dist/account-manager/db/migrations/index.js +2 -0
  9. package/dist/account-manager/db/migrations/index.js.map +1 -1
  10. package/dist/account-manager/db/schema/authorization-request.d.ts +19 -0
  11. package/dist/account-manager/db/schema/authorization-request.d.ts.map +1 -0
  12. package/dist/account-manager/db/schema/authorization-request.js +5 -0
  13. package/dist/account-manager/db/schema/authorization-request.js.map +1 -0
  14. package/dist/account-manager/db/schema/device-account.d.ts +14 -0
  15. package/dist/account-manager/db/schema/device-account.d.ts.map +1 -0
  16. package/dist/account-manager/db/schema/device-account.js +5 -0
  17. package/dist/account-manager/db/schema/device-account.js.map +1 -0
  18. package/dist/account-manager/db/schema/device.d.ts +16 -0
  19. package/dist/account-manager/db/schema/device.d.ts.map +1 -0
  20. package/dist/account-manager/db/schema/device.js +5 -0
  21. package/dist/account-manager/db/schema/device.js.map +1 -0
  22. package/dist/account-manager/db/schema/index.d.ts +11 -1
  23. package/dist/account-manager/db/schema/index.d.ts.map +1 -1
  24. package/dist/account-manager/db/schema/token.d.ts +24 -0
  25. package/dist/account-manager/db/schema/token.d.ts.map +1 -0
  26. package/dist/account-manager/db/schema/token.js +5 -0
  27. package/dist/account-manager/db/schema/token.js.map +1 -0
  28. package/dist/account-manager/db/schema/used-refresh-token.d.ts +12 -0
  29. package/dist/account-manager/db/schema/used-refresh-token.d.ts.map +1 -0
  30. package/dist/account-manager/db/schema/used-refresh-token.js +5 -0
  31. package/dist/account-manager/db/schema/used-refresh-token.js.map +1 -0
  32. package/dist/account-manager/helpers/account.d.ts +27 -5
  33. package/dist/account-manager/helpers/account.d.ts.map +1 -1
  34. package/dist/account-manager/helpers/account.js +15 -14
  35. package/dist/account-manager/helpers/account.js.map +1 -1
  36. package/dist/account-manager/helpers/authorization-request.d.ts +12 -0
  37. package/dist/account-manager/helpers/authorization-request.d.ts.map +1 -0
  38. package/dist/account-manager/helpers/authorization-request.js +59 -0
  39. package/dist/account-manager/helpers/authorization-request.js.map +1 -0
  40. package/dist/account-manager/helpers/device-account.d.ts +108 -0
  41. package/dist/account-manager/helpers/device-account.d.ts.map +1 -0
  42. package/dist/account-manager/helpers/device-account.js +82 -0
  43. package/dist/account-manager/helpers/device-account.js.map +1 -0
  44. package/dist/account-manager/helpers/device.d.ts +9 -0
  45. package/dist/account-manager/helpers/device.d.ts.map +1 -0
  46. package/dist/account-manager/helpers/device.js +32 -0
  47. package/dist/account-manager/helpers/device.js.map +1 -0
  48. package/dist/account-manager/helpers/token.d.ts +485 -0
  49. package/dist/account-manager/helpers/token.d.ts.map +1 -0
  50. package/dist/account-manager/helpers/token.js +123 -0
  51. package/dist/account-manager/helpers/token.js.map +1 -0
  52. package/dist/account-manager/helpers/used-refresh-token.d.ts +10 -0
  53. package/dist/account-manager/helpers/used-refresh-token.d.ts.map +1 -0
  54. package/dist/account-manager/helpers/used-refresh-token.js +25 -0
  55. package/dist/account-manager/helpers/used-refresh-token.js.map +1 -0
  56. package/dist/account-manager/index.d.ts +36 -6
  57. package/dist/account-manager/index.d.ts.map +1 -1
  58. package/dist/account-manager/index.js +223 -22
  59. package/dist/account-manager/index.js.map +1 -1
  60. package/dist/actor-store/preference/reader.js.map +1 -1
  61. package/dist/actor-store/record/reader.d.ts +1 -1
  62. package/dist/api/app/bsky/util/resolver.d.ts +1 -1
  63. package/dist/api/com/atproto/server/createSession.d.ts.map +1 -1
  64. package/dist/api/com/atproto/server/createSession.js +7 -31
  65. package/dist/api/com/atproto/server/createSession.js.map +1 -1
  66. package/dist/api/com/atproto/server/deleteSession.d.ts.map +1 -1
  67. package/dist/api/com/atproto/server/deleteSession.js +14 -13
  68. package/dist/api/com/atproto/server/deleteSession.js.map +1 -1
  69. package/dist/api/com/atproto/server/getSession.d.ts.map +1 -1
  70. package/dist/api/com/atproto/server/getSession.js +4 -2
  71. package/dist/api/com/atproto/server/getSession.js.map +1 -1
  72. package/dist/api/com/atproto/server/refreshSession.d.ts.map +1 -1
  73. package/dist/api/com/atproto/server/refreshSession.js +4 -2
  74. package/dist/api/com/atproto/server/refreshSession.js.map +1 -1
  75. package/dist/api/com/atproto/sync/getRepoStatus.d.ts.map +1 -1
  76. package/dist/api/com/atproto/sync/getRepoStatus.js +2 -1
  77. package/dist/api/com/atproto/sync/getRepoStatus.js.map +1 -1
  78. package/dist/api/com/atproto/sync/listRepos.js +2 -2
  79. package/dist/api/com/atproto/sync/listRepos.js.map +1 -1
  80. package/dist/api/proxy.d.ts.map +1 -1
  81. package/dist/api/proxy.js +15 -2
  82. package/dist/api/proxy.js.map +1 -1
  83. package/dist/auth-routes.d.ts +4 -0
  84. package/dist/auth-routes.d.ts.map +1 -0
  85. package/dist/auth-routes.js +24 -0
  86. package/dist/auth-routes.js.map +1 -0
  87. package/dist/auth-verifier.d.ts +32 -11
  88. package/dist/auth-verifier.d.ts.map +1 -1
  89. package/dist/auth-verifier.js +238 -79
  90. package/dist/auth-verifier.js.map +1 -1
  91. package/dist/config/config.d.ts +12 -0
  92. package/dist/config/config.d.ts.map +1 -1
  93. package/dist/config/config.js +45 -0
  94. package/dist/config/config.js.map +1 -1
  95. package/dist/config/env.d.ts +8 -0
  96. package/dist/config/env.d.ts.map +1 -1
  97. package/dist/config/env.js +10 -0
  98. package/dist/config/env.js.map +1 -1
  99. package/dist/config/secrets.d.ts +1 -0
  100. package/dist/config/secrets.d.ts.map +1 -1
  101. package/dist/config/secrets.js +1 -0
  102. package/dist/config/secrets.js.map +1 -1
  103. package/dist/context.d.ts +6 -0
  104. package/dist/context.d.ts.map +1 -1
  105. package/dist/context.js +71 -13
  106. package/dist/context.js.map +1 -1
  107. package/dist/db/cast.d.ts +15 -0
  108. package/dist/db/cast.d.ts.map +1 -0
  109. package/dist/db/cast.js +66 -0
  110. package/dist/db/cast.js.map +1 -0
  111. package/dist/db/db.d.ts +2 -2
  112. package/dist/db/db.d.ts.map +1 -1
  113. package/dist/db/db.js +9 -7
  114. package/dist/db/db.js.map +1 -1
  115. package/dist/db/index.d.ts +1 -0
  116. package/dist/db/index.d.ts.map +1 -1
  117. package/dist/db/index.js +1 -0
  118. package/dist/db/index.js.map +1 -1
  119. package/dist/error.d.ts.map +1 -1
  120. package/dist/error.js +5 -0
  121. package/dist/error.js.map +1 -1
  122. package/dist/index.d.ts.map +1 -1
  123. package/dist/index.js +2 -0
  124. package/dist/index.js.map +1 -1
  125. package/dist/logger.d.ts +13 -11
  126. package/dist/logger.d.ts.map +1 -1
  127. package/dist/logger.js +80 -64
  128. package/dist/logger.js.map +1 -1
  129. package/dist/oauth/detailed-account-store.d.ts +27 -0
  130. package/dist/oauth/detailed-account-store.d.ts.map +1 -0
  131. package/dist/oauth/detailed-account-store.js +76 -0
  132. package/dist/oauth/detailed-account-store.js.map +1 -0
  133. package/dist/oauth/provider.d.ts +16 -0
  134. package/dist/oauth/provider.d.ts.map +1 -0
  135. package/dist/oauth/provider.js +45 -0
  136. package/dist/oauth/provider.js.map +1 -0
  137. package/dist/pipethrough.d.ts.map +1 -1
  138. package/dist/pipethrough.js.map +1 -1
  139. package/dist/sequencer/events.d.ts +2 -2
  140. package/example.env +21 -3
  141. package/package.json +6 -4
  142. package/src/account-manager/db/migrations/004-oauth.ts +122 -0
  143. package/src/account-manager/db/migrations/index.ts +2 -0
  144. package/src/account-manager/db/schema/authorization-request.ts +26 -0
  145. package/src/account-manager/db/schema/device-account.ts +15 -0
  146. package/src/account-manager/db/schema/device.ts +18 -0
  147. package/src/account-manager/db/schema/index.ts +15 -0
  148. package/src/account-manager/db/schema/token.ts +34 -0
  149. package/src/account-manager/db/schema/used-refresh-token.ts +13 -0
  150. package/src/account-manager/helpers/account.ts +16 -21
  151. package/src/account-manager/helpers/authorization-request.ts +82 -0
  152. package/src/account-manager/helpers/device-account.ts +135 -0
  153. package/src/account-manager/helpers/device.ts +45 -0
  154. package/src/account-manager/helpers/token.ts +185 -0
  155. package/src/account-manager/helpers/used-refresh-token.ts +30 -0
  156. package/src/account-manager/index.ts +325 -20
  157. package/src/actor-store/preference/reader.ts +1 -1
  158. package/src/api/com/atproto/server/createSession.ts +8 -44
  159. package/src/api/com/atproto/server/deleteSession.ts +14 -20
  160. package/src/api/com/atproto/server/getSession.ts +7 -2
  161. package/src/api/com/atproto/server/refreshSession.ts +6 -2
  162. package/src/api/com/atproto/sync/getRepoStatus.ts +3 -1
  163. package/src/api/com/atproto/sync/listRepos.ts +1 -1
  164. package/src/api/proxy.ts +18 -2
  165. package/src/auth-routes.ts +27 -0
  166. package/src/auth-verifier.ts +312 -92
  167. package/src/config/config.ts +66 -0
  168. package/src/config/env.ts +24 -0
  169. package/src/config/secrets.ts +2 -0
  170. package/src/context.ts +80 -14
  171. package/src/db/cast.ts +59 -0
  172. package/src/db/db.ts +15 -12
  173. package/src/db/index.ts +1 -0
  174. package/src/error.ts +7 -0
  175. package/src/index.ts +2 -0
  176. package/src/logger.ts +83 -38
  177. package/src/oauth/detailed-account-store.ts +96 -0
  178. package/src/oauth/provider.ts +77 -0
  179. package/src/pipethrough.ts +3 -2
package/src/config/env.ts CHANGED
@@ -6,14 +6,22 @@ export const readEnv = (): ServerEnvironment => {
6
6
  port: envInt('PDS_PORT'),
7
7
  hostname: envStr('PDS_HOSTNAME'),
8
8
  serviceDid: envStr('PDS_SERVICE_DID'),
9
+ serviceName: envStr('PDS_SERVICE_NAME'),
9
10
  version: envStr('PDS_VERSION'),
11
+ homeUrl: envStr('PDS_HOME_URL'),
12
+ logoUrl: envStr('PDS_LOGO_URL'),
10
13
  privacyPolicyUrl: envStr('PDS_PRIVACY_POLICY_URL'),
14
+ supportUrl: envStr('PDS_SUPPORT_URL'),
11
15
  termsOfServiceUrl: envStr('PDS_TERMS_OF_SERVICE_URL'),
12
16
  contactEmailAddress: envStr('PDS_CONTACT_EMAIL_ADDRESS'),
13
17
  acceptingImports: envBool('PDS_ACCEPTING_REPO_IMPORTS'),
14
18
  blobUploadLimit: envInt('PDS_BLOB_UPLOAD_LIMIT'),
15
19
  devMode: envBool('PDS_DEV_MODE'),
16
20
 
21
+ // branding
22
+ primaryColor: envStr('PDS_PRIMARY_COLOR'),
23
+ errorColor: envStr('PDS_ERROR_COLOR'),
24
+
17
25
  // database
18
26
  dataDirectory: envStr('PDS_DATA_DIRECTORY'),
19
27
  disableWalAutoCheckpoint: envBool('PDS_SQLITE_DISABLE_WAL_AUTO_CHECKPOINT'),
@@ -97,6 +105,7 @@ export const readEnv = (): ServerEnvironment => {
97
105
  crawlers: envList('PDS_CRAWLERS'),
98
106
 
99
107
  // secrets
108
+ dpopSecret: envStr('PDS_DPOP_SECRET'),
100
109
  jwtSecret: envStr('PDS_JWT_SECRET'),
101
110
  adminPassword: envStr('PDS_ADMIN_PASSWORD'),
102
111
 
@@ -106,6 +115,9 @@ export const readEnv = (): ServerEnvironment => {
106
115
  plcRotationKeyK256PrivateKeyHex: envStr(
107
116
  'PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX',
108
117
  ),
118
+
119
+ // fetch
120
+ fetchDisableSsrfProtection: envBool('PDS_DISABLE_SSRF_PROTECTION'),
109
121
  }
110
122
  }
111
123
 
@@ -114,14 +126,22 @@ export type ServerEnvironment = {
114
126
  port?: number
115
127
  hostname?: string
116
128
  serviceDid?: string
129
+ serviceName?: string
117
130
  version?: string
131
+ homeUrl?: string
132
+ logoUrl?: string
118
133
  privacyPolicyUrl?: string
134
+ supportUrl?: string
119
135
  termsOfServiceUrl?: string
120
136
  contactEmailAddress?: string
121
137
  acceptingImports?: boolean
122
138
  blobUploadLimit?: number
123
139
  devMode?: boolean
124
140
 
141
+ // branding
142
+ primaryColor?: string
143
+ errorColor?: string
144
+
125
145
  // database
126
146
  dataDirectory?: string
127
147
  disableWalAutoCheckpoint?: boolean
@@ -203,10 +223,14 @@ export type ServerEnvironment = {
203
223
  crawlers?: string[]
204
224
 
205
225
  // secrets
226
+ dpopSecret?: string
206
227
  jwtSecret?: string
207
228
  adminPassword?: string
208
229
 
209
230
  // keys
210
231
  plcRotationKeyKmsKeyId?: string
211
232
  plcRotationKeyK256PrivateKeyHex?: string
233
+
234
+ // fetch
235
+ fetchDisableSsrfProtection?: boolean
212
236
  }
@@ -27,6 +27,7 @@ export const envToSecrets = (env: ServerEnvironment): ServerSecrets => {
27
27
  }
28
28
 
29
29
  return {
30
+ dpopSecret: env.dpopSecret,
30
31
  jwtSecret: env.jwtSecret,
31
32
  adminPassword: env.adminPassword,
32
33
  plcRotationKey,
@@ -34,6 +35,7 @@ export const envToSecrets = (env: ServerEnvironment): ServerSecrets => {
34
35
  }
35
36
 
36
37
  export type ServerSecrets = {
38
+ dpopSecret?: string
37
39
  jwtSecret: string
38
40
  adminPassword: string
39
41
  plcRotationKey: SigningKeyKms | SigningKeyMemory
package/src/context.ts CHANGED
@@ -12,12 +12,21 @@ import {
12
12
  RateLimiterOpts,
13
13
  createServiceAuthHeaders,
14
14
  } from '@atproto/xrpc-server'
15
+ import {
16
+ JoseKey,
17
+ Fetch,
18
+ safeFetchWrap,
19
+ OAuthVerifier,
20
+ } from '@atproto/oauth-provider'
21
+
15
22
  import { ServerConfig, ServerSecrets } from './config'
23
+ import { PdsOAuthProvider } from './oauth/provider'
16
24
  import {
17
25
  AuthVerifier,
18
26
  createPublicKeyObject,
19
27
  createSecretKeyObject,
20
28
  } from './auth-verifier'
29
+ import { fetchLogger } from './logger'
21
30
  import { ServerMailer } from './mailer'
22
31
  import { ModerationMailer } from './mailer/moderation'
23
32
  import { BlobStore } from '@atproto/repo'
@@ -50,6 +59,8 @@ export type AppContextOptions = {
50
59
  moderationAgent?: AtpAgent
51
60
  reportingAgent?: AtpAgent
52
61
  entrywayAgent?: AtpAgent
62
+ safeFetch: Fetch
63
+ authProvider?: PdsOAuthProvider
53
64
  authVerifier: AuthVerifier
54
65
  plcRotationKey: crypto.Keypair
55
66
  cfg: ServerConfig
@@ -74,7 +85,9 @@ export class AppContext {
74
85
  public moderationAgent: AtpAgent | undefined
75
86
  public reportingAgent: AtpAgent | undefined
76
87
  public entrywayAgent: AtpAgent | undefined
88
+ public safeFetch: Fetch
77
89
  public authVerifier: AuthVerifier
90
+ public authProvider?: PdsOAuthProvider
78
91
  public plcRotationKey: crypto.Keypair
79
92
  public cfg: ServerConfig
80
93
 
@@ -97,7 +110,9 @@ export class AppContext {
97
110
  this.moderationAgent = opts.moderationAgent
98
111
  this.reportingAgent = opts.reportingAgent
99
112
  this.entrywayAgent = opts.entrywayAgent
113
+ this.safeFetch = opts.safeFetch
100
114
  this.authVerifier = opts.authVerifier
115
+ this.authProvider = opts.authProvider
101
116
  this.plcRotationKey = opts.plcRotationKey
102
117
  this.cfg = opts.cfg
103
118
  }
@@ -206,7 +221,12 @@ export class AppContext {
206
221
  : undefined
207
222
 
208
223
  const jwtSecretKey = createSecretKeyObject(secrets.jwtSecret)
224
+ const jwtPublicKey = cfg.entryway
225
+ ? createPublicKeyObject(cfg.entryway.jwtPublicKeyHex)
226
+ : null
227
+
209
228
  const accountManager = new AccountManager(
229
+ backgroundQueue,
210
230
  cfg.db.accountDbLoc,
211
231
  jwtSecretKey,
212
232
  cfg.service.did,
@@ -214,20 +234,6 @@ export class AppContext {
214
234
  )
215
235
  await accountManager.migrateOrThrow()
216
236
 
217
- const jwtKey = cfg.entryway
218
- ? createPublicKeyObject(cfg.entryway.jwtPublicKeyHex)
219
- : jwtSecretKey
220
-
221
- const authVerifier = new AuthVerifier(accountManager, idResolver, {
222
- jwtKey, // @TODO support multiple keys?
223
- adminPass: secrets.adminPassword,
224
- dids: {
225
- pds: cfg.service.did,
226
- entryway: cfg.entryway?.did,
227
- modService: cfg.modService?.did,
228
- },
229
- })
230
-
231
237
  const plcRotationKey =
232
238
  secrets.plcRotationKey.provider === 'kms'
233
239
  ? await KmsKeypair.load({
@@ -250,6 +256,64 @@ export class AppContext {
250
256
  appviewCdnUrlPattern: cfg.bskyAppView?.cdnUrlPattern,
251
257
  })
252
258
 
259
+ // A fetch() function that protects against SSRF attacks, large responses &
260
+ // known bad domains. This function can safely be used to fetch user
261
+ // provided URLs (unless "disableSsrfProtection" is true, of course).
262
+ const safeFetch = safeFetchWrap({
263
+ allowHttp: cfg.fetch.disableSsrfProtection,
264
+ responseMaxSize: 512 * 1024, // 512kB
265
+ ssrfProtection: !cfg.fetch.disableSsrfProtection,
266
+ fetch: async (input, init) => {
267
+ const request = input instanceof Request ? input : null
268
+ const method = init?.method ?? request?.method ?? 'GET'
269
+ const uri = request?.url ?? String(input)
270
+ fetchLogger.debug({ method, uri }, 'fetch')
271
+ return globalThis.fetch(input, init)
272
+ },
273
+ })
274
+
275
+ const authProvider = cfg.oauth.provider
276
+ ? new PdsOAuthProvider({
277
+ issuer: cfg.oauth.issuer,
278
+ keyset: [
279
+ // Note: OpenID compatibility would require an RS256 private key in this list
280
+ await JoseKey.fromKeyLike(jwtSecretKey, undefined, 'HS256'),
281
+ ],
282
+ accountManager,
283
+ actorStore,
284
+ localViewer,
285
+ redis: redisScratch,
286
+ dpopSecret: secrets.dpopSecret,
287
+ customization: cfg.oauth.provider.customization,
288
+ safeFetch,
289
+ })
290
+ : undefined
291
+
292
+ const oauthVerifier: OAuthVerifier =
293
+ authProvider ?? // OAuthProvider extends OAuthVerifier
294
+ new OAuthVerifier({
295
+ issuer: cfg.oauth.issuer,
296
+ keyset: [await JoseKey.fromKeyLike(jwtPublicKey!, undefined, 'ES256K')],
297
+ dpopSecret: secrets.dpopSecret,
298
+ redis: redisScratch,
299
+ })
300
+
301
+ const authVerifier = new AuthVerifier(
302
+ accountManager,
303
+ idResolver,
304
+ oauthVerifier,
305
+ {
306
+ publicUrl: cfg.service.publicUrl,
307
+ jwtKey: jwtPublicKey ?? jwtSecretKey,
308
+ adminPass: secrets.adminPassword,
309
+ dids: {
310
+ pds: cfg.service.did,
311
+ entryway: cfg.entryway?.did,
312
+ modService: cfg.modService?.did,
313
+ },
314
+ },
315
+ )
316
+
253
317
  return new AppContext({
254
318
  actorStore,
255
319
  blobstore,
@@ -269,7 +333,9 @@ export class AppContext {
269
333
  moderationAgent,
270
334
  reportingAgent,
271
335
  entrywayAgent,
336
+ safeFetch,
272
337
  authVerifier,
338
+ authProvider,
273
339
  plcRotationKey,
274
340
  cfg,
275
341
  ...(overrides ?? {}),
package/src/db/cast.ts ADDED
@@ -0,0 +1,59 @@
1
+ export type DateISO = `${string}T${string}Z`
2
+ export const toDateISO = (date: Date): DateISO => date.toISOString() as DateISO
3
+ export const fromDateISO = (date: DateISO): Date => new Date(date)
4
+
5
+ export type Json = string
6
+ export const toJson = (obj: unknown): Json => {
7
+ const json = JSON.stringify(obj)
8
+ if (json === undefined) throw new TypeError('Input not JSONifyable')
9
+ return json as Json
10
+ }
11
+ export const fromJson = <T>(json: Json): T => {
12
+ try {
13
+ return JSON.parse(json) as T
14
+ } catch (cause) {
15
+ throw new TypeError('Database contains invalid JSON', { cause })
16
+ }
17
+ }
18
+
19
+ export type JsonArray = `[${string}]`
20
+ export const isJsonArray = (json: string): json is JsonArray =>
21
+ // Although the JSON in the DB should have been encoded using toJson,
22
+ // there should not be any leading or trailing whitespace. We will still trim
23
+ // the string to protect against any manual editing of the DB.
24
+ json.trimStart().startsWith('[') && json.trimEnd().endsWith(']')
25
+ export function assertJsonArray(json: string): asserts json is JsonArray {
26
+ if (!isJsonArray(json)) throw new TypeError('Not an Array')
27
+ }
28
+ export const toJsonArray = (obj: readonly unknown[]): JsonArray => {
29
+ const json = toJson(obj)
30
+ assertJsonArray(json)
31
+ return json as JsonArray
32
+ }
33
+ export const fromJsonArray = <T>(json: JsonArray): T[] => {
34
+ assertJsonArray(json)
35
+ return fromJson(json) as T[]
36
+ }
37
+
38
+ export type JsonObject = `{${string}}`
39
+ const isJsonObject = (json: string): json is JsonObject =>
40
+ // Although the JSON in the DB should have been encoded using toJson,
41
+ // there should not be any leading or trailing whitespace. We will still trim
42
+ // the string to protect against any manual editing of the DB.
43
+ json.trimStart().startsWith('{') && json.trimEnd().endsWith('}')
44
+ function assertJsonObject(json: string): asserts json is JsonObject {
45
+ if (!isJsonObject(json)) throw new TypeError('Not an Object')
46
+ }
47
+ export const toJsonObject = (
48
+ obj: Readonly<Record<string, unknown>>,
49
+ ): JsonObject => {
50
+ const json = toJson(obj)
51
+ assertJsonObject(json)
52
+ return json as JsonObject
53
+ }
54
+ export const fromJsonObject = <T extends Record<string, unknown>>(
55
+ json: JsonObject,
56
+ ): T => {
57
+ assertJsonObject(json)
58
+ return fromJson(json) as T
59
+ }
package/src/db/db.ts CHANGED
@@ -51,7 +51,7 @@ export class Database<Schema> {
51
51
  }
52
52
 
53
53
  async transactionNoRetry<T>(
54
- fn: (db: Database<Schema>) => Promise<T>,
54
+ fn: (db: Database<Schema>) => T | Promise<T>,
55
55
  ): Promise<T> {
56
56
  this.assertNotTransaction()
57
57
  const leakyTxPlugin = new LeakyTxPlugin()
@@ -60,22 +60,25 @@ export class Database<Schema> {
60
60
  .transaction()
61
61
  .execute(async (txn) => {
62
62
  const dbTxn = new Database(txn)
63
- const txRes = await fn(dbTxn)
64
- .catch(async (err) => {
65
- leakyTxPlugin.endTx()
66
- // ensure that all in-flight queries are flushed & the connection is open
67
- await dbTxn.db.getExecutor().provideConnection(async () => {})
68
- throw err
69
- })
70
- .finally(() => leakyTxPlugin.endTx())
71
- const hooks = dbTxn.commitHooks
72
- return { hooks, txRes }
63
+ try {
64
+ const txRes = await fn(dbTxn)
65
+ leakyTxPlugin.endTx()
66
+ const hooks = dbTxn.commitHooks
67
+ return { hooks, txRes }
68
+ } catch (err) {
69
+ leakyTxPlugin.endTx()
70
+ // ensure that all in-flight queries are flushed & the connection is open
71
+ await txn.getExecutor().provideConnection(async () => {})
72
+ throw err
73
+ }
73
74
  })
74
75
  hooks.map((hook) => hook())
75
76
  return txRes
76
77
  }
77
78
 
78
- async transaction<T>(fn: (db: Database<Schema>) => Promise<T>): Promise<T> {
79
+ async transaction<T>(
80
+ fn: (db: Database<Schema>) => T | Promise<T>,
81
+ ): Promise<T> {
79
82
  return retrySqlite(() => this.transactionNoRetry(fn))
80
83
  }
81
84
 
package/src/db/index.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from './db'
2
+ export * from './cast'
2
3
  export * from './migrator'
3
4
  export * from './util'
package/src/error.ts CHANGED
@@ -1,12 +1,19 @@
1
1
  import { XRPCError } from '@atproto/xrpc-server'
2
2
  import { ErrorRequestHandler } from 'express'
3
3
  import { httpLogger as log } from './logger'
4
+ import { OAuthError } from '@atproto/oauth-provider'
4
5
 
5
6
  export const handler: ErrorRequestHandler = (err, _req, res, next) => {
6
7
  log.error(err, 'unexpected internal server error')
7
8
  if (res.headersSent) {
8
9
  return next(err)
9
10
  }
11
+
12
+ if (err instanceof OAuthError) {
13
+ res.status(err.status).json(err.toJSON())
14
+ return
15
+ }
16
+
10
17
  const serverError = XRPCError.fromError(err)
11
18
  res.status(serverError.type).json(serverError.payload)
12
19
  }
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ import events from 'events'
11
11
  import { Options as XrpcServerOptions } from '@atproto/xrpc-server'
12
12
  import { DAY, HOUR, MINUTE, SECOND } from '@atproto/common'
13
13
  import API from './api'
14
+ import * as authRoutes from './auth-routes'
14
15
  import * as basicRoutes from './basic-routes'
15
16
  import * as wellKnown from './well-known'
16
17
  import * as error from './error'
@@ -99,6 +100,7 @@ export class PDS {
99
100
 
100
101
  server = API(server, ctx)
101
102
 
103
+ app.use(authRoutes.createRouter(ctx))
102
104
  app.use(basicRoutes.createRouter(ctx))
103
105
  app.use(wellKnown.createRouter(ctx))
104
106
  app.use(server.xrpc.router)
package/src/logger.ts CHANGED
@@ -1,8 +1,6 @@
1
- import pino from 'pino'
1
+ import { stdSerializers } from 'pino'
2
2
  import pinoHttp from 'pino-http'
3
3
  import { subsystemLogger } from '@atproto/common'
4
- import * as jose from 'jose'
5
- import { parseBasicAuth } from './auth-verifier'
6
4
 
7
5
  export const dbLogger = subsystemLogger('pds:db')
8
6
  export const didCacheLogger = subsystemLogger('pds:did-cache')
@@ -13,44 +11,91 @@ export const mailerLogger = subsystemLogger('pds:mailer')
13
11
  export const labelerLogger = subsystemLogger('pds:labeler')
14
12
  export const crawlerLogger = subsystemLogger('pds:crawler')
15
13
  export const httpLogger = subsystemLogger('pds')
14
+ export const fetchLogger = subsystemLogger('pds:fetch')
15
+ export const oauthLogger = subsystemLogger('pds:oauth')
16
16
 
17
17
  export const loggerMiddleware = pinoHttp({
18
18
  logger: httpLogger,
19
19
  serializers: {
20
- err: (err) => {
21
- return {
22
- code: err?.code,
23
- message: err?.message,
24
- }
25
- },
26
- req: (req) => {
27
- const serialized = pino.stdSerializers.req(req)
28
- const authHeader = serialized.headers.authorization || ''
29
- let auth: string | undefined = undefined
30
- if (authHeader.startsWith('Bearer ')) {
31
- const token = authHeader.slice('Bearer '.length)
32
- const { sub } = jose.decodeJwt(token)
33
- if (sub) {
34
- auth = 'Bearer ' + sub
35
- } else {
36
- auth = 'Bearer Invalid'
37
- }
38
- }
39
- if (authHeader.startsWith('Basic ')) {
40
- const parsed = parseBasicAuth(authHeader)
41
- if (!parsed) {
42
- auth = 'Basic Invalid'
43
- } else {
44
- auth = 'Basic ' + parsed.username
45
- }
46
- }
47
- return {
48
- ...serialized,
49
- headers: {
50
- ...serialized.headers,
51
- authorization: auth,
52
- },
53
- }
54
- },
20
+ err: errSerializer,
21
+ req: reqSerializer,
55
22
  },
56
23
  })
24
+
25
+ function errSerializer(err: any) {
26
+ return {
27
+ code: err?.code,
28
+ message: err?.message,
29
+ }
30
+ }
31
+
32
+ function reqSerializer(req: any) {
33
+ const serialized = stdSerializers.req(req)
34
+ serialized.headers = obfuscateHeaders(serialized.headers)
35
+ return serialized
36
+ }
37
+
38
+ function obfuscateHeaders(headers: Record<string, string>) {
39
+ const obfuscatedHeaders: Record<string, string> = {}
40
+ for (const key in headers) {
41
+ if (key.toLowerCase() === 'authorization') {
42
+ obfuscatedHeaders[key] = obfuscateAuthHeader(headers[key])
43
+ } else if (key.toLowerCase() === 'dpop') {
44
+ obfuscatedHeaders[key] = obfuscateJws(headers[key]) || 'Invalid'
45
+ } else {
46
+ obfuscatedHeaders[key] = headers[key]
47
+ }
48
+ }
49
+ return obfuscatedHeaders
50
+ }
51
+
52
+ function obfuscateAuthHeader(authHeader: string): string {
53
+ // This is a hot path (runs on every request). Avoid using split() or regex.
54
+
55
+ const spaceIdx = authHeader.indexOf(' ')
56
+ if (spaceIdx === -1) return 'Invalid'
57
+
58
+ const type = authHeader.slice(0, spaceIdx)
59
+ switch (type.toLowerCase()) {
60
+ case 'bearer':
61
+ return `${type} ${obfuscateBearer(authHeader.slice(spaceIdx + 1))}`
62
+ case 'dpop':
63
+ return `${type} ${obfuscateJws(authHeader.slice(spaceIdx + 1)) || 'Invalid'}`
64
+ case 'basic':
65
+ return `${type} ${obfuscateBasic(authHeader.slice(spaceIdx + 1)) || 'Invalid'}`
66
+ default:
67
+ return `Invalid`
68
+ }
69
+ }
70
+
71
+ function obfuscateBasic(token: string): null | string {
72
+ if (!token) return null
73
+ const buffer = Buffer.from(token, 'base64')
74
+ if (!buffer.length) return null // Buffer.from will silently ignore invalid base64 chars
75
+ const authHeader = buffer.toString('utf8')
76
+ const colIdx = authHeader.indexOf(':')
77
+ if (colIdx === -1) return null
78
+ const username = authHeader.slice(0, colIdx)
79
+ return `${username}:***`
80
+ }
81
+
82
+ function obfuscateBearer(token: string): string {
83
+ return obfuscateJws(token) || obfuscateToken(token)
84
+ }
85
+
86
+ function obfuscateToken(token: string): string {
87
+ return token ? '***' : ''
88
+ }
89
+
90
+ function obfuscateJws(token: string): null | string {
91
+ const firstDot = token.indexOf('.')
92
+ if (firstDot === -1) return null
93
+
94
+ const secondDot = token.indexOf('.', firstDot + 1)
95
+ if (secondDot === -1) return null
96
+
97
+ if (token.indexOf('.', secondDot + 1) !== -1) return null
98
+
99
+ // Strip the signature
100
+ return token.slice(0, secondDot) + '.obfuscated'
101
+ }
@@ -0,0 +1,96 @@
1
+ import {
2
+ AccountInfo,
3
+ AccountStore,
4
+ DeviceId,
5
+ LoginCredentials,
6
+ } from '@atproto/oauth-provider'
7
+
8
+ import { AccountManager } from '../account-manager/index'
9
+ import { ActorStore } from '../actor-store/index'
10
+ import { ProfileViewBasic } from '../lexicon/types/app/bsky/actor/defs'
11
+ import { LocalViewerCreator } from '../read-after-write/index'
12
+
13
+ /**
14
+ * Although the {@link AccountManager} class implements the {@link AccountStore}
15
+ * interface, the accounts it returns do not contain any profile information
16
+ * (display name, avatar, etc). This is due to the fact that the account manager
17
+ * does not have access to the account's repos. The {@link DetailedAccountStore}
18
+ * is a wrapper around the {@link AccountManager} that enriches the accounts
19
+ * with profile information using the account's repos through the
20
+ * {@link ActorStore}.
21
+ */
22
+ export class DetailedAccountStore implements AccountStore {
23
+ constructor(
24
+ private accountManager: AccountManager,
25
+ private actorStore: ActorStore,
26
+ private localViewer: LocalViewerCreator,
27
+ ) {}
28
+
29
+ private async getProfile(did: string): Promise<ProfileViewBasic | null> {
30
+ // TODO: Should we cache this?
31
+ return this.actorStore.read(did, async (actorStoreReader) => {
32
+ const localViewer = this.localViewer(actorStoreReader)
33
+ return localViewer.getProfileBasic()
34
+ })
35
+ }
36
+
37
+ private async enrichAccountInfo(
38
+ accountInfo: AccountInfo,
39
+ ): Promise<AccountInfo> {
40
+ const { account } = accountInfo
41
+ if (!account.picture || !account.name) {
42
+ const profile = await this.getProfile(account.sub)
43
+ if (profile) {
44
+ account.picture ||= profile.avatar
45
+ account.name ||= profile.displayName
46
+ }
47
+ }
48
+
49
+ return accountInfo
50
+ }
51
+
52
+ async authenticateAccount(
53
+ credentials: LoginCredentials,
54
+ deviceId: DeviceId,
55
+ ): Promise<AccountInfo | null> {
56
+ const accountInfo = await this.accountManager.authenticateAccount(
57
+ credentials,
58
+ deviceId,
59
+ )
60
+ if (!accountInfo) return null
61
+ return this.enrichAccountInfo(accountInfo)
62
+ }
63
+
64
+ async addAuthorizedClient(
65
+ deviceId: DeviceId,
66
+ sub: string,
67
+ clientId: string,
68
+ ): Promise<void> {
69
+ return this.accountManager.addAuthorizedClient(deviceId, sub, clientId)
70
+ }
71
+
72
+ async getDeviceAccount(
73
+ deviceId: DeviceId,
74
+ sub: string,
75
+ ): Promise<AccountInfo | null> {
76
+ const accountInfo = await this.accountManager.getDeviceAccount(
77
+ deviceId,
78
+ sub,
79
+ )
80
+ if (!accountInfo) return null
81
+ return this.enrichAccountInfo(accountInfo)
82
+ }
83
+
84
+ async listDeviceAccounts(deviceId: DeviceId): Promise<AccountInfo[]> {
85
+ const accountInfos = await this.accountManager.listDeviceAccounts(deviceId)
86
+ return Promise.all(
87
+ accountInfos.map(async (accountInfo) =>
88
+ this.enrichAccountInfo(accountInfo),
89
+ ),
90
+ )
91
+ }
92
+
93
+ async removeDeviceAccount(deviceId: DeviceId, sub: string): Promise<void> {
94
+ return this.accountManager.removeDeviceAccount(deviceId, sub)
95
+ }
96
+ }