@atproto/pds 0.4.34 → 0.4.36

Sign up to get free protection for your applications and to get access to all the features.
Files changed (179) hide show
  1. package/CHANGELOG.md +17 -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 +9 -7
  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
+ }