@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
@@ -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