@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
@@ -1,12 +1,18 @@
1
1
  import { KeyObject, createPublicKey, createSecretKey } from 'node:crypto'
2
+
3
+ import {
4
+ OAuthError,
5
+ OAuthVerifier,
6
+ WWWAuthenticateError,
7
+ } from '@atproto/oauth-provider'
2
8
  import {
3
9
  AuthRequiredError,
4
10
  ForbiddenError,
5
11
  InvalidRequestError,
12
+ XRPCError,
6
13
  verifyJwt as verifyServiceJwt,
7
14
  } from '@atproto/xrpc-server'
8
15
  import { IdResolver, getDidKeyFromMultibase } from '@atproto/identity'
9
- import * as ui8 from 'uint8arrays'
10
16
  import express from 'express'
11
17
  import * as jose from 'jose'
12
18
  import KeyEncoder from 'key-encoder'
@@ -16,6 +22,8 @@ import { getVerificationMaterial } from '@atproto/common'
16
22
 
17
23
  type ReqCtx = {
18
24
  req: express.Request
25
+ // StreamAuthVerifier does not have "res"
26
+ res?: express.Response
19
27
  }
20
28
 
21
29
  // @TODO sync-up with current method names, consider backwards compat.
@@ -94,7 +102,12 @@ type ValidatedBearer = {
94
102
  audience: string | undefined
95
103
  }
96
104
 
105
+ type ValidatedRefreshBearer = ValidatedBearer & {
106
+ tokenId: string
107
+ }
108
+
97
109
  export type AuthVerifierOpts = {
110
+ publicUrl: string
98
111
  jwtKey: KeyObject
99
112
  adminPass: string
100
113
  dids: {
@@ -105,6 +118,7 @@ export type AuthVerifierOpts = {
105
118
  }
106
119
 
107
120
  export class AuthVerifier {
121
+ private _publicUrl: string
108
122
  private _jwtKey: KeyObject
109
123
  private _adminPass: string
110
124
  public dids: AuthVerifierOpts['dids']
@@ -112,8 +126,10 @@ export class AuthVerifier {
112
126
  constructor(
113
127
  public accountManager: AccountManager,
114
128
  public idResolver: IdResolver,
129
+ public oauthVerifier: OAuthVerifier,
115
130
  opts: AuthVerifierOpts,
116
131
  ) {
132
+ this._publicUrl = opts.publicUrl
117
133
  this._jwtKey = opts.jwtKey
118
134
  this._adminPass = opts.adminPass
119
135
  this.dids = opts.dids
@@ -125,7 +141,7 @@ export class AuthVerifier {
125
141
  (opts: Partial<AccessOpts> = {}) =>
126
142
  (ctx: ReqCtx): Promise<AccessOutput> => {
127
143
  return this.validateAccessToken(
128
- ctx.req,
144
+ ctx,
129
145
  [
130
146
  AuthScope.Access,
131
147
  AuthScope.AppPassPrivileged,
@@ -140,7 +156,7 @@ export class AuthVerifier {
140
156
  (opts: Partial<AccessOpts> = {}) =>
141
157
  (ctx: ReqCtx): Promise<AccessOutput> => {
142
158
  return this.validateAccessToken(
143
- ctx.req,
159
+ ctx,
144
160
  [AuthScope.Access, ...(opts.additional ?? [])],
145
161
  opts,
146
162
  )
@@ -149,7 +165,7 @@ export class AuthVerifier {
149
165
  accessPrivileged =
150
166
  (opts: Partial<AccessOpts> = {}) =>
151
167
  (ctx: ReqCtx): Promise<AccessOutput> => {
152
- return this.validateAccessToken(ctx.req, [
168
+ return this.validateAccessToken(ctx, [
153
169
  AuthScope.Access,
154
170
  AuthScope.AppPassPrivileged,
155
171
  ...(opts.additional ?? []),
@@ -157,55 +173,56 @@ export class AuthVerifier {
157
173
  }
158
174
 
159
175
  refresh = async (ctx: ReqCtx): Promise<RefreshOutput> => {
160
- const { did, scope, token, audience, payload } =
161
- await this.validateBearerToken(ctx.req, [AuthScope.Refresh], {
162
- // when using entryway, proxying refresh credentials
163
- audience: this.dids.entryway ? this.dids.entryway : this.dids.pds,
164
- })
165
- if (!payload.jti) {
166
- throw new AuthRequiredError(
167
- 'Unexpected missing refresh token id',
168
- 'MissingTokenId',
169
- )
170
- }
176
+ const { did, scope, token, tokenId, audience } =
177
+ await this.validateRefreshToken(ctx)
178
+
171
179
  return {
172
180
  credentials: {
173
181
  type: 'refresh',
174
182
  did,
175
183
  scope,
176
184
  audience,
177
- tokenId: payload.jti,
185
+ tokenId,
178
186
  },
179
187
  artifacts: token,
180
188
  }
181
189
  }
182
190
 
183
- adminToken = (ctx: ReqCtx): AdminTokenOutput => {
184
- const parsed = parseBasicAuth(ctx.req.headers.authorization || '')
185
- if (!parsed) {
186
- throw new AuthRequiredError()
187
- }
188
- const { username, password } = parsed
189
- if (username !== 'admin' || password !== this._adminPass) {
190
- throw new AuthRequiredError()
191
+ refreshExpired = async (ctx: ReqCtx): Promise<RefreshOutput> => {
192
+ const { did, scope, token, tokenId, audience } =
193
+ await this.validateRefreshToken(ctx, { clockTolerance: Infinity })
194
+
195
+ return {
196
+ credentials: {
197
+ type: 'refresh',
198
+ did,
199
+ scope,
200
+ audience,
201
+ tokenId,
202
+ },
203
+ artifacts: token,
191
204
  }
192
- return { credentials: { type: 'admin_token' } }
205
+ }
206
+
207
+ adminToken = async (ctx: ReqCtx): Promise<AdminTokenOutput> => {
208
+ this.setAuthHeaders(ctx)
209
+ return this.validateAdminToken(ctx)
193
210
  }
194
211
 
195
212
  optionalAccessOrAdminToken = async (
196
213
  ctx: ReqCtx,
197
214
  ): Promise<AccessOutput | AdminTokenOutput | NullOutput> => {
198
- if (isBearerToken(ctx.req)) {
215
+ if (isAccessToken(ctx.req)) {
199
216
  return await this.accessStandard()(ctx)
200
217
  } else if (isBasicToken(ctx.req)) {
201
218
  return await this.adminToken(ctx)
202
219
  } else {
203
- return this.null()
220
+ return this.null(ctx)
204
221
  }
205
222
  }
206
223
 
207
- userDidAuth = async (reqCtx: ReqCtx): Promise<UserDidOutput> => {
208
- const payload = await this.verifyServiceJwt(reqCtx, {
224
+ userDidAuth = async (ctx: ReqCtx): Promise<UserDidOutput> => {
225
+ const payload = await this.verifyServiceJwt(ctx, {
209
226
  aud: this.dids.entryway ?? this.dids.pds,
210
227
  iss: null,
211
228
  })
@@ -219,20 +236,20 @@ export class AuthVerifier {
219
236
  }
220
237
 
221
238
  userDidAuthOptional = async (
222
- reqCtx: ReqCtx,
239
+ ctx: ReqCtx,
223
240
  ): Promise<UserDidOutput | NullOutput> => {
224
- if (isBearerToken(reqCtx.req)) {
225
- return await this.userDidAuth(reqCtx)
241
+ if (isBearerToken(ctx.req)) {
242
+ return await this.userDidAuth(ctx)
226
243
  } else {
227
- return this.null()
244
+ return this.null(ctx)
228
245
  }
229
246
  }
230
247
 
231
- modService = async (reqCtx: ReqCtx): Promise<ModServiceOutput> => {
248
+ modService = async (ctx: ReqCtx): Promise<ModServiceOutput> => {
232
249
  if (!this.dids.modService) {
233
250
  throw new AuthRequiredError('Untrusted issuer', 'UntrustedIss')
234
251
  }
235
- const payload = await this.verifyServiceJwt(reqCtx, {
252
+ const payload = await this.verifyServiceJwt(ctx, {
236
253
  aud: null,
237
254
  iss: [this.dids.modService, `${this.dids.modService}#atproto_labeler`],
238
255
  })
@@ -255,25 +272,74 @@ export class AuthVerifier {
255
272
  }
256
273
 
257
274
  moderator = async (
258
- reqCtx: ReqCtx,
275
+ ctx: ReqCtx,
259
276
  ): Promise<AdminTokenOutput | ModServiceOutput> => {
260
- if (isBearerToken(reqCtx.req)) {
261
- return this.modService(reqCtx)
277
+ if (isBearerToken(ctx.req)) {
278
+ return this.modService(ctx)
262
279
  } else {
263
- return this.adminToken(reqCtx)
280
+ return this.adminToken(ctx)
264
281
  }
265
282
  }
266
283
 
267
- async validateBearerToken(
268
- req: express.Request,
284
+ protected async validateAdminToken({
285
+ req,
286
+ }: ReqCtx): Promise<AdminTokenOutput> {
287
+ const parsed = parseBasicAuth(req.headers.authorization)
288
+ if (!parsed) {
289
+ throw new AuthRequiredError()
290
+ }
291
+ const { username, password } = parsed
292
+ if (username !== 'admin' || password !== this._adminPass) {
293
+ throw new AuthRequiredError()
294
+ }
295
+
296
+ return { credentials: { type: 'admin_token' } }
297
+ }
298
+
299
+ protected async validateRefreshToken(
300
+ ctx: ReqCtx,
301
+ verifyOptions?: Omit<jose.JWTVerifyOptions, 'audience'>,
302
+ ): Promise<ValidatedRefreshBearer> {
303
+ const result = await this.validateBearerToken(ctx, [AuthScope.Refresh], {
304
+ ...verifyOptions,
305
+ // when using entryway, proxying refresh credentials
306
+ audience: this.dids.entryway ? this.dids.entryway : this.dids.pds,
307
+ })
308
+ const tokenId = result.payload.jti
309
+ if (!tokenId) {
310
+ throw new AuthRequiredError(
311
+ 'Unexpected missing refresh token id',
312
+ 'MissingTokenId',
313
+ )
314
+ }
315
+ return { ...result, tokenId }
316
+ }
317
+
318
+ protected async validateBearerToken(
319
+ ctx: ReqCtx,
269
320
  scopes: AuthScope[],
270
321
  verifyOptions?: jose.JWTVerifyOptions,
271
322
  ): Promise<ValidatedBearer> {
272
- const token = bearerTokenFromReq(req)
323
+ this.setAuthHeaders(ctx)
324
+
325
+ const token = bearerTokenFromReq(ctx.req)
273
326
  if (!token) {
274
327
  throw new AuthRequiredError(undefined, 'AuthMissing')
275
328
  }
276
- const payload = await verifyJwt({ key: this._jwtKey, token, verifyOptions })
329
+
330
+ const { payload, protectedHeader } = await this.jwtVerify(
331
+ token,
332
+ verifyOptions,
333
+ )
334
+
335
+ if (protectedHeader.typ === 'dpop+jwt') {
336
+ // @TODO we should make sure that bearer access tokens do have their "typ"
337
+ // claim, and allow list the possible value(s) here (typically "at+jwt"),
338
+ // instead of using a deny list. This would be more secure & future proof
339
+ // against new token types that would be introduced in the future
340
+ throw new InvalidRequestError('Malformed token', 'InvalidToken')
341
+ }
342
+
277
343
  const { sub, aud, scope } = payload
278
344
  if (typeof sub !== 'string' || !sub.startsWith('did:')) {
279
345
  throw new InvalidRequestError('Malformed token', 'InvalidToken')
@@ -284,6 +350,10 @@ export class AuthVerifier {
284
350
  ) {
285
351
  throw new InvalidRequestError('Malformed token', 'InvalidToken')
286
352
  }
353
+ if ((payload.cnf as any)?.jkt) {
354
+ // DPoP bound tokens must not be usable as regular Bearer tokens
355
+ throw new InvalidRequestError('Malformed token', 'InvalidToken')
356
+ }
287
357
  if (!isAuthScope(scope) || (scopes.length > 0 && !scopes.includes(scope))) {
288
358
  throw new InvalidRequestError('Bad token scope', 'InvalidToken')
289
359
  }
@@ -296,22 +366,45 @@ export class AuthVerifier {
296
366
  }
297
367
  }
298
368
 
299
- async validateAccessToken(
300
- req: express.Request,
369
+ protected async validateAccessToken(
370
+ ctx: ReqCtx,
301
371
  scopes: AuthScope[],
302
- opts?: { checkTakedown?: boolean; checkDeactivated?: boolean },
372
+ {
373
+ checkTakedown = false,
374
+ checkDeactivated = false,
375
+ }: { checkTakedown?: boolean; checkDeactivated?: boolean } = {},
303
376
  ): Promise<AccessOutput> {
304
- const { did, scope, token, audience } = await this.validateBearerToken(
305
- req,
306
- scopes,
307
- { audience: this.dids.pds },
308
- )
309
- const { checkTakedown = false, checkDeactivated = false } = opts ?? {}
377
+ this.setAuthHeaders(ctx)
378
+
379
+ let accessOutput: AccessOutput
380
+
381
+ const [type] = parseAuthorizationHeader(ctx.req.headers.authorization)
382
+ switch (type) {
383
+ case AuthType.BEARER: {
384
+ accessOutput = await this.validateBearerAccessToken(ctx, scopes)
385
+ break
386
+ }
387
+ case AuthType.DPOP: {
388
+ accessOutput = await this.validateDpopAccessToken(ctx, scopes)
389
+ break
390
+ }
391
+ case null:
392
+ throw new AuthRequiredError(undefined, 'AuthMissing')
393
+ default:
394
+ throw new InvalidRequestError(
395
+ 'Unexpected authorization type',
396
+ 'InvalidToken',
397
+ )
398
+ }
399
+
310
400
  if (checkTakedown || checkDeactivated) {
311
- const found = await this.accountManager.getAccount(did, {
312
- includeDeactivated: true,
313
- includeTakenDown: true,
314
- })
401
+ const found = await this.accountManager.getAccount(
402
+ accessOutput.credentials.did,
403
+ {
404
+ includeDeactivated: true,
405
+ includeTakenDown: true,
406
+ },
407
+ )
315
408
  if (!found) {
316
409
  // will be turned into ExpiredToken for the client if proxied by entryway
317
410
  throw new ForbiddenError('Account not found', 'AccountNotFound')
@@ -329,6 +422,82 @@ export class AuthVerifier {
329
422
  )
330
423
  }
331
424
  }
425
+
426
+ return accessOutput
427
+ }
428
+
429
+ protected async validateDpopAccessToken(
430
+ ctx: ReqCtx,
431
+ scopes: AuthScope[],
432
+ ): Promise<AccessOutput> {
433
+ if (!scopes.includes(AuthScope.Access)) {
434
+ throw new InvalidRequestError(
435
+ 'DPoP access token cannot be used for this request',
436
+ 'InvalidToken',
437
+ )
438
+ }
439
+
440
+ this.setAuthHeaders(ctx)
441
+
442
+ const { req, res } = ctx
443
+
444
+ // https://datatracker.ietf.org/doc/html/rfc9449#section-8.2
445
+ if (res) {
446
+ const dpopNonce = this.oauthVerifier.nextDpopNonce()
447
+ if (dpopNonce) {
448
+ res.setHeader('DPoP-Nonce', dpopNonce)
449
+ res.appendHeader('Access-Control-Expose-Headers', 'DPoP-Nonce')
450
+ }
451
+ }
452
+
453
+ try {
454
+ const url = new URL(req.originalUrl || req.url, this._publicUrl)
455
+ const result = await this.oauthVerifier.authenticateRequest(
456
+ req.method,
457
+ url,
458
+ req.headers,
459
+ { audience: [this.dids.pds] },
460
+ )
461
+
462
+ const { sub } = result.claims
463
+ if (typeof sub !== 'string' || !sub.startsWith('did:')) {
464
+ throw new InvalidRequestError('Malformed token', 'InvalidToken')
465
+ }
466
+
467
+ return {
468
+ credentials: {
469
+ type: 'access',
470
+ did: result.claims.sub,
471
+ scope: AuthScope.Access,
472
+ audience: this.dids.pds,
473
+ },
474
+ artifacts: result.token,
475
+ }
476
+ } catch (err) {
477
+ // Make sure to include any WWW-Authenticate header in the response
478
+ // (particularly useful for DPoP's "use_dpop_nonce" error)
479
+ if (res && err instanceof WWWAuthenticateError) {
480
+ res.setHeader('WWW-Authenticate', err.wwwAuthenticateHeader)
481
+ res.appendHeader('Access-Control-Expose-Headers', 'WWW-Authenticate')
482
+ }
483
+
484
+ if (err instanceof OAuthError) {
485
+ throw new XRPCError(err.status, err.error_description, err.error)
486
+ }
487
+
488
+ throw err
489
+ }
490
+ }
491
+
492
+ protected async validateBearerAccessToken(
493
+ ctx: ReqCtx,
494
+ scopes: AuthScope[],
495
+ ): Promise<AccessOutput> {
496
+ const { did, scope, token, audience } = await this.validateBearerToken(
497
+ ctx,
498
+ scopes,
499
+ { audience: this.dids.pds },
500
+ )
332
501
  return {
333
502
  credentials: {
334
503
  type: 'access',
@@ -340,10 +509,12 @@ export class AuthVerifier {
340
509
  }
341
510
  }
342
511
 
343
- async verifyServiceJwt(
344
- reqCtx: ReqCtx,
512
+ protected async verifyServiceJwt(
513
+ ctx: ReqCtx,
345
514
  opts: { aud: string | null; iss: string[] | null },
346
515
  ) {
516
+ this.setAuthHeaders(ctx)
517
+
347
518
  const getSigningKey = async (
348
519
  iss: string,
349
520
  forceRefresh: boolean,
@@ -369,7 +540,7 @@ export class AuthVerifier {
369
540
  return didKey
370
541
  }
371
542
 
372
- const jwtStr = bearerTokenFromReq(reqCtx.req)
543
+ const jwtStr = bearerTokenFromReq(ctx.req)
373
544
  if (!jwtStr) {
374
545
  throw new AuthRequiredError('missing jwt', 'MissingJwt')
375
546
  }
@@ -377,7 +548,8 @@ export class AuthVerifier {
377
548
  return { iss: payload.iss, aud: payload.aud }
378
549
  }
379
550
 
380
- null(): NullOutput {
551
+ protected null(ctx: ReqCtx): NullOutput {
552
+ this.setAuthHeaders(ctx)
381
553
  return {
382
554
  credentials: null,
383
555
  }
@@ -395,59 +567,93 @@ export class AuthVerifier {
395
567
  return auth.credentials.did === did
396
568
  }
397
569
  }
570
+
571
+ protected async jwtVerify(
572
+ token: string,
573
+ verifyOptions?: jose.JWTVerifyOptions,
574
+ ) {
575
+ try {
576
+ return await jose.jwtVerify(token, this._jwtKey, verifyOptions)
577
+ } catch (err) {
578
+ if (err?.['code'] === 'ERR_JWT_EXPIRED') {
579
+ throw new InvalidRequestError('Token has expired', 'ExpiredToken')
580
+ }
581
+ throw new InvalidRequestError(
582
+ 'Token could not be verified',
583
+ 'InvalidToken',
584
+ )
585
+ }
586
+ }
587
+
588
+ protected setAuthHeaders({ res }: ReqCtx) {
589
+ if (res) {
590
+ res.setHeader('Cache-Control', 'private')
591
+ vary(res, 'Authorization')
592
+ }
593
+ }
398
594
  }
399
595
 
400
596
  // HELPERS
401
597
  // ---------
402
598
 
403
- const BEARER = 'Bearer '
404
- const BASIC = 'Basic '
599
+ enum AuthType {
600
+ BASIC = 'Basic',
601
+ BEARER = 'Bearer',
602
+ DPOP = 'DPoP',
603
+ }
604
+
605
+ export const parseAuthorizationHeader = (
606
+ authorization?: string,
607
+ ): [type: null] | [type: AuthType, token: string] => {
608
+ const result = authorization?.split(' ', 3)
609
+ if (result?.length === 2) {
610
+ for (const [name, type] of Object.entries(AuthType)) {
611
+ // authorization type is case-insensitive
612
+ if (name === result[0].toUpperCase()) {
613
+ return [type, result[1]] as [type: AuthType, token: string]
614
+ }
615
+ }
616
+ }
617
+
618
+ return [null] as [type: null]
619
+ }
620
+
621
+ const isAccessToken = (req: express.Request): boolean => {
622
+ const [type] = parseAuthorizationHeader(req.headers.authorization)
623
+ return type === AuthType.BEARER || type === AuthType.DPOP
624
+ }
405
625
 
406
626
  const isBearerToken = (req: express.Request): boolean => {
407
- return req.headers.authorization?.startsWith(BEARER) ?? false
627
+ const [type] = parseAuthorizationHeader(req.headers.authorization)
628
+ return type === AuthType.BEARER
408
629
  }
409
630
 
410
631
  const isBasicToken = (req: express.Request): boolean => {
411
- return req.headers.authorization?.startsWith(BASIC) ?? false
632
+ const [type] = parseAuthorizationHeader(req.headers.authorization)
633
+ return type === AuthType.BASIC
412
634
  }
413
635
 
414
636
  const bearerTokenFromReq = (req: express.Request) => {
415
- const header = req.headers.authorization || ''
416
- if (!header.startsWith(BEARER)) return null
417
- return header.slice(BEARER.length)
418
- }
419
-
420
- const verifyJwt = async (params: {
421
- key: KeyObject
422
- token: string
423
- verifyOptions?: jose.JWTVerifyOptions
424
- }): Promise<jose.JWTPayload> => {
425
- const { key, token, verifyOptions } = params
426
- try {
427
- const result = await jose.jwtVerify(token, key, verifyOptions)
428
- return result.payload
429
- } catch (err) {
430
- if (err?.['code'] === 'ERR_JWT_EXPIRED') {
431
- throw new InvalidRequestError('Token has expired', 'ExpiredToken')
432
- }
433
- throw new InvalidRequestError('Token could not be verified', 'InvalidToken')
434
- }
637
+ const [type, token] = parseAuthorizationHeader(req.headers.authorization)
638
+ return type === AuthType.BEARER ? token : null
435
639
  }
436
640
 
437
641
  export const parseBasicAuth = (
438
- token: string,
642
+ authorizationHeader?: string,
439
643
  ): { username: string; password: string } | null => {
440
- if (!token.startsWith(BASIC)) return null
441
- const b64 = token.slice(BASIC.length)
442
- let parsed: string[]
443
644
  try {
444
- parsed = ui8.toString(ui8.fromString(b64, 'base64pad'), 'utf8').split(':')
645
+ const [type, b64] = parseAuthorizationHeader(authorizationHeader)
646
+ if (type !== AuthType.BASIC) return null
647
+ const decoded = Buffer.from(b64, 'base64').toString('utf8')
648
+ // We must not use split(':') because the password can contain colons
649
+ const colon = decoded.indexOf(':')
650
+ if (colon === -1) return null
651
+ const username = decoded.slice(0, colon)
652
+ const password = decoded.slice(colon + 1)
653
+ return { username, password }
445
654
  } catch (err) {
446
655
  return null
447
656
  }
448
- const [username, password] = parsed
449
- if (!username || !password) return null
450
- return { username, password }
451
657
  }
452
658
 
453
659
  const authScopes = new Set(Object.values(AuthScope))
@@ -465,3 +671,17 @@ export const createPublicKeyObject = (publicKeyHex: string): KeyObject => {
465
671
  }
466
672
 
467
673
  const keyEncoder = new KeyEncoder('secp256k1')
674
+
675
+ function vary(res: express.Response, value: string) {
676
+ const current = res.getHeader('Vary')
677
+ if (current == null || typeof current === 'number') {
678
+ res.setHeader('Vary', value)
679
+ } else {
680
+ const alreadyIncluded = Array.isArray(current)
681
+ ? current.some((value) => value.includes(value))
682
+ : current.includes(value)
683
+ if (!alreadyIncluded) {
684
+ res.appendHeader('Vary', value)
685
+ }
686
+ }
687
+ }
@@ -1,6 +1,7 @@
1
1
  import path from 'node:path'
2
2
  import assert from 'node:assert'
3
3
  import { DAY, HOUR, SECOND } from '@atproto/common'
4
+ import { Customization } from '@atproto/oauth-provider'
4
5
  import { ServerEnvironment } from './env'
5
6
 
6
7
  // off-config but still from env:
@@ -234,6 +235,54 @@ export const envToCfg = (env: ServerEnvironment): ServerConfig => {
234
235
 
235
236
  const crawlersCfg: ServerConfig['crawlers'] = env.crawlers ?? []
236
237
 
238
+ const fetchCfg: ServerConfig['fetch'] = {
239
+ disableSsrfProtection: env.fetchDisableSsrfProtection ?? false,
240
+ }
241
+
242
+ const oauthCfg: ServerConfig['oauth'] = entrywayCfg
243
+ ? {
244
+ issuer: entrywayCfg.url,
245
+ provider: false,
246
+ }
247
+ : {
248
+ issuer: serviceCfg.publicUrl,
249
+ provider: {
250
+ customization: {
251
+ name: env.serviceName ?? 'Personal PDS',
252
+ logo: env.logoUrl,
253
+ colors: {
254
+ primary: env.primaryColor,
255
+ error: env.errorColor,
256
+ },
257
+ links: [
258
+ {
259
+ title: 'Home',
260
+ href: env.homeUrl,
261
+ rel: 'bookmark',
262
+ },
263
+ {
264
+ title: 'Terms of Service',
265
+ href: env.termsOfServiceUrl,
266
+ rel: 'terms-of-service',
267
+ },
268
+ {
269
+ title: 'Privacy Policy',
270
+ href: env.privacyPolicyUrl,
271
+ rel: 'privacy-policy',
272
+ },
273
+ {
274
+ title: 'Support',
275
+ href: env.supportUrl,
276
+ rel: 'help',
277
+ },
278
+ ].filter(
279
+ (f): f is typeof f & { href: NonNullable<(typeof f)['href']> } =>
280
+ f.href != null,
281
+ ),
282
+ },
283
+ },
284
+ }
285
+
237
286
  return {
238
287
  service: serviceCfg,
239
288
  db: dbCfg,
@@ -251,6 +300,8 @@ export const envToCfg = (env: ServerEnvironment): ServerConfig => {
251
300
  redis: redisCfg,
252
301
  rateLimits: rateLimitsCfg,
253
302
  crawlers: crawlersCfg,
303
+ fetch: fetchCfg,
304
+ oauth: oauthCfg,
254
305
  }
255
306
  }
256
307
 
@@ -271,6 +322,8 @@ export type ServerConfig = {
271
322
  redis: RedisScratchConfig | null
272
323
  rateLimits: RateLimitsConfig
273
324
  crawlers: string[]
325
+ fetch: FetchConfig
326
+ oauth: OAuthConfig
274
327
  }
275
328
 
276
329
  export type ServiceConfig = {
@@ -337,6 +390,19 @@ export type EntrywayConfig = {
337
390
  plcRotationKey: string
338
391
  }
339
392
 
393
+ export type FetchConfig = {
394
+ disableSsrfProtection: boolean
395
+ }
396
+
397
+ export type OAuthConfig = {
398
+ issuer: string
399
+ provider:
400
+ | false
401
+ | {
402
+ customization: Customization
403
+ }
404
+ }
405
+
340
406
  export type InvitesConfig =
341
407
  | {
342
408
  required: true