@atproto/pds 0.5.0 → 0.5.1

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 (71) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/api/app/bsky/actor/getPreferences.d.ts.map +1 -1
  3. package/dist/api/app/bsky/actor/getPreferences.js +7 -2
  4. package/dist/api/app/bsky/actor/getPreferences.js.map +1 -1
  5. package/dist/api/app/bsky/actor/putPreferences.d.ts.map +1 -1
  6. package/dist/api/app/bsky/actor/putPreferences.js +7 -2
  7. package/dist/api/app/bsky/actor/putPreferences.js.map +1 -1
  8. package/dist/api/com/atproto/server/getServiceAuth.d.ts.map +1 -1
  9. package/dist/api/com/atproto/server/getServiceAuth.js +4 -0
  10. package/dist/api/com/atproto/server/getServiceAuth.js.map +1 -1
  11. package/dist/config/config.d.ts +5 -2
  12. package/dist/config/config.d.ts.map +1 -1
  13. package/dist/config/config.js +50 -46
  14. package/dist/config/config.js.map +1 -1
  15. package/dist/config/env.d.ts +1 -0
  16. package/dist/config/env.d.ts.map +1 -1
  17. package/dist/config/env.js +1 -0
  18. package/dist/config/env.js.map +1 -1
  19. package/dist/context.js +1 -1
  20. package/dist/context.js.map +1 -1
  21. package/dist/lexicons/chat/bsky/actor/getStatus.defs.d.ts +2 -0
  22. package/dist/lexicons/chat/bsky/actor/getStatus.defs.d.ts.map +1 -1
  23. package/dist/lexicons/chat/bsky/actor/getStatus.defs.js +1 -0
  24. package/dist/lexicons/chat/bsky/actor/getStatus.defs.js.map +1 -1
  25. package/dist/lexicons/chat/bsky/convo/defs.defs.d.ts +1 -1
  26. package/dist/lexicons/chat/bsky/convo/defs.defs.d.ts.map +1 -1
  27. package/dist/lexicons/chat/bsky/convo/defs.defs.js +1 -1
  28. package/dist/lexicons/chat/bsky/convo/defs.defs.js.map +1 -1
  29. package/dist/lexicons/com/atproto/server/getServiceAuth.defs.d.ts +2 -2
  30. package/dist/lexicons/com/atproto/server/getServiceAuth.defs.js +1 -1
  31. package/dist/lexicons/com/atproto/server/getServiceAuth.defs.js.map +1 -1
  32. package/dist/mailer/index.d.ts +3 -3
  33. package/dist/mailer/index.d.ts.map +1 -1
  34. package/dist/mailer/index.js +18 -9
  35. package/dist/mailer/index.js.map +1 -1
  36. package/dist/mailer/templates/confirm-email.js +11 -3
  37. package/dist/mailer/templates/confirm-email.js.map +2 -2
  38. package/dist/mailer/templates/delete-account.js +2 -2
  39. package/dist/mailer/templates/delete-account.js.map +2 -2
  40. package/dist/mailer/templates/plc-operation.js +2 -2
  41. package/dist/mailer/templates/plc-operation.js.map +2 -2
  42. package/dist/mailer/templates/reset-password.js +2 -2
  43. package/dist/mailer/templates/reset-password.js.map +2 -2
  44. package/dist/mailer/templates/update-email.js +2 -2
  45. package/dist/mailer/templates/update-email.js.map +2 -2
  46. package/dist/mailer/templates.d.ts +11 -0
  47. package/dist/mailer/templates.d.ts.map +1 -1
  48. package/dist/mailer/templates.js.map +1 -1
  49. package/dist/pipethrough.d.ts +3 -0
  50. package/dist/pipethrough.d.ts.map +1 -1
  51. package/dist/pipethrough.js +25 -9
  52. package/dist/pipethrough.js.map +1 -1
  53. package/package.json +11 -10
  54. package/src/api/app/bsky/actor/getPreferences.ts +11 -2
  55. package/src/api/app/bsky/actor/putPreferences.ts +11 -2
  56. package/src/api/com/atproto/server/getServiceAuth.ts +7 -0
  57. package/src/config/config.ts +69 -57
  58. package/src/config/env.ts +3 -0
  59. package/src/context.ts +1 -1
  60. package/src/mailer/index.ts +25 -9
  61. package/src/mailer/templates/confirm-email.hbs +18 -17
  62. package/src/mailer/templates/delete-account.hbs +6 -6
  63. package/src/mailer/templates/plc-operation.hbs +6 -6
  64. package/src/mailer/templates/reset-password.hbs +7 -7
  65. package/src/mailer/templates/update-email.hbs +6 -6
  66. package/src/mailer/templates.ts +12 -0
  67. package/src/pipethrough.ts +33 -12
  68. package/tests/app-passwords.test.ts +5 -5
  69. package/tests/get-service-auth.test.ts +81 -0
  70. package/tests/proxied/proxy-header.test.ts +1 -0
  71. package/tests/proxied/proxy-oauth-aud.test.ts +175 -0
@@ -4,14 +4,12 @@
4
4
  <meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
5
5
  <meta name="x-apple-disable-message-reformatting" />
6
6
  <title>Confirm your email</title>
7
- <meta
8
- name="description"
9
- content="To confirm your email, enter the code provided in the app."
10
- />
7
+ <meta name="description" content="{{token}} is your verification code." />
11
8
  </head>
12
9
  <div
13
10
  style="display:none;overflow:hidden;line-height:1px;opacity:0;max-height:0;max-width:0"
14
- >To confirm your email, enter the code provided in the app.<div
11
+ >{{token}}
12
+ is your verification code.<div
15
13
  > ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏</div>
16
14
  </div>
17
15
 
@@ -42,8 +40,8 @@
42
40
  <tbody>
43
41
  <tr>
44
42
  <td><img
45
- alt="Bluesky"
46
- src="https://bsky.social/about/images/email/email_logo_default.png"
43
+ alt="{{config.serviceName}}"
44
+ src="{{config.logoUrl}}"
47
45
  style="display:block;outline:none;border:none;text-decoration:none;width:110px;margin:0 auto"
48
46
  /></td>
49
47
  </tr>
@@ -67,12 +65,15 @@
67
65
  <p
68
66
  style="font-size:16px;line-height:1.4;margin:0px 0px;letter-spacing:0.25px;color:hsl(211, 24%, 34.2%);font-family:-apple-system, BlinkMacSystemFont, &#x27;Roboto&#x27;, &#x27;Oxygen&#x27;, &#x27;Ubuntu&#x27;, &#x27;Cantarell&#x27;, &#x27;Fira Sans&#x27;, &#x27;Droid Sans&#x27;, &#x27;Helvetica Neue&#x27;, sans-serif;padding-top:12px;padding-bottom:12px;padding-right:32px"
69
67
  >To confirm this email for your account, please enter the
70
- code below in the app or<!-- -->
71
- <a
72
- href="https://bsky.app/intent/verify-email?code={{token}}"
73
- style="color:#067df7;text-decoration:none;text-decoration-line:underline;font-size:16px;letter-spacing:0.25px"
74
- target="_blank"
75
- >click here.</a></p><code
68
+ code below in the app{{#if
69
+ config.showBskyAppEmailConfirmationLink
70
+ }}
71
+ or<!-- -->
72
+ <a
73
+ href="https://bsky.app/intent/verify-email?code={{token}}"
74
+ style="color:{{config.primaryColor}};text-decoration:none;text-decoration-line:underline;font-size:16px;letter-spacing:0.25px"
75
+ target="_blank"
76
+ >click here</a>{{/if}}.</p><code
76
77
  style="display:block;padding:16px;border-radius:8px;border-width:1px;border-style:solid;background-color:hsl(211, 20%, 95.3%);border-color:hsl(211, 20%, 85.89999999999999%);font-size:14px;letter-spacing:0.25px;font-family:monospace;text-transform:uppercase"
77
78
  >{{token}}</code>
78
79
  <p
@@ -109,17 +110,17 @@
109
110
  <p
110
111
  style="font-size:14px;line-height:1.4;margin:0px 0px;color:hsl(211, 20%, 53%);font-family:-apple-system, BlinkMacSystemFont, &#x27;Roboto&#x27;, &#x27;Oxygen&#x27;, &#x27;Ubuntu&#x27;, &#x27;Cantarell&#x27;, &#x27;Fira Sans&#x27;, &#x27;Droid Sans&#x27;, &#x27;Helvetica Neue&#x27;, sans-serif;letter-spacing:0.25px"
111
112
  ><a
112
- href="https://bsky.app"
113
+ href="{{config.homeUrl}}"
113
114
  style="color:hsl(211, 20%, 53%);text-decoration:none;text-decoration-line:underline;font-family:-apple-system, BlinkMacSystemFont, &#x27;Roboto&#x27;, &#x27;Oxygen&#x27;, &#x27;Ubuntu&#x27;, &#x27;Cantarell&#x27;, &#x27;Fira Sans&#x27;, &#x27;Droid Sans&#x27;, &#x27;Helvetica Neue&#x27;, sans-serif;margin:0px 0px;line-height:1.0;font-size:14px;letter-spacing:0.25px"
114
115
  target="_blank"
115
- >Bluesky</a>, the social internet</p>
116
+ >{{config.serviceName}}</a></p>
116
117
  </td>
117
118
  <td
118
119
  data-id="__react-email-column"
119
120
  style="width:24px"
120
121
  ><img
121
- alt="🦋"
122
- src="https://bsky.social/about/images/email/email_mark_dark.png"
122
+ alt="{{config.serviceName}}"
123
+ src="{{config.markUrl}}"
123
124
  style="display:block;outline:none;border:none;text-decoration:none;width:24px"
124
125
  /></td>
125
126
  </tr>
@@ -43,8 +43,8 @@
43
43
  <tbody>
44
44
  <tr>
45
45
  <td><img
46
- alt="Bluesky"
47
- src="https://bsky.social/about/images/email/email_logo_default.png"
46
+ alt="{{config.serviceName}}"
47
+ src="{{config.logoUrl}}"
48
48
  style="display:block;outline:none;border:none;text-decoration:none;width:110px;margin:0 auto"
49
49
  /></td>
50
50
  </tr>
@@ -108,17 +108,17 @@
108
108
  <p
109
109
  style="font-size:14px;line-height:1.4;margin:0px 0px;color:hsl(211, 20%, 53%);font-family:-apple-system, BlinkMacSystemFont, &#x27;Roboto&#x27;, &#x27;Oxygen&#x27;, &#x27;Ubuntu&#x27;, &#x27;Cantarell&#x27;, &#x27;Fira Sans&#x27;, &#x27;Droid Sans&#x27;, &#x27;Helvetica Neue&#x27;, sans-serif;letter-spacing:0.25px"
110
110
  ><a
111
- href="https://bsky.app"
111
+ href="{{config.homeUrl}}"
112
112
  style="color:hsl(211, 20%, 53%);text-decoration:none;text-decoration-line:underline;font-family:-apple-system, BlinkMacSystemFont, &#x27;Roboto&#x27;, &#x27;Oxygen&#x27;, &#x27;Ubuntu&#x27;, &#x27;Cantarell&#x27;, &#x27;Fira Sans&#x27;, &#x27;Droid Sans&#x27;, &#x27;Helvetica Neue&#x27;, sans-serif;margin:0px 0px;line-height:1.0;font-size:14px;letter-spacing:0.25px"
113
113
  target="_blank"
114
- >Bluesky</a>, the social internet</p>
114
+ >{{config.serviceName}}</a></p>
115
115
  </td>
116
116
  <td
117
117
  data-id="__react-email-column"
118
118
  style="width:24px"
119
119
  ><img
120
- alt="🦋"
121
- src="https://bsky.social/about/images/email/email_mark_dark.png"
120
+ alt="{{config.serviceName}}"
121
+ src="{{config.markUrl}}"
122
122
  style="display:block;outline:none;border:none;text-decoration:none;width:24px"
123
123
  /></td>
124
124
  </tr>
@@ -42,8 +42,8 @@
42
42
  <tbody>
43
43
  <tr>
44
44
  <td><img
45
- alt="Bluesky"
46
- src="https://bsky.social/about/images/email/email_logo_default.png"
45
+ alt="{{config.serviceName}}"
46
+ src="{{config.logoUrl}}"
47
47
  style="display:block;outline:none;border:none;text-decoration:none;width:110px;margin:0 auto"
48
48
  /></td>
49
49
  </tr>
@@ -105,17 +105,17 @@
105
105
  <p
106
106
  style="font-size:14px;line-height:1.4;margin:0px 0px;color:hsl(211, 20%, 53%);font-family:-apple-system, BlinkMacSystemFont, &#x27;Roboto&#x27;, &#x27;Oxygen&#x27;, &#x27;Ubuntu&#x27;, &#x27;Cantarell&#x27;, &#x27;Fira Sans&#x27;, &#x27;Droid Sans&#x27;, &#x27;Helvetica Neue&#x27;, sans-serif;letter-spacing:0.25px"
107
107
  ><a
108
- href="https://bsky.app"
108
+ href="{{config.homeUrl}}"
109
109
  style="color:hsl(211, 20%, 53%);text-decoration:none;text-decoration-line:underline;font-family:-apple-system, BlinkMacSystemFont, &#x27;Roboto&#x27;, &#x27;Oxygen&#x27;, &#x27;Ubuntu&#x27;, &#x27;Cantarell&#x27;, &#x27;Fira Sans&#x27;, &#x27;Droid Sans&#x27;, &#x27;Helvetica Neue&#x27;, sans-serif;margin:0px 0px;line-height:1.0;font-size:14px;letter-spacing:0.25px"
110
110
  target="_blank"
111
- >Bluesky</a>, the social internet</p>
111
+ >{{config.serviceName}}</a></p>
112
112
  </td>
113
113
  <td
114
114
  data-id="__react-email-column"
115
115
  style="width:24px"
116
116
  ><img
117
- alt="🦋"
118
- src="https://bsky.social/about/images/email/email_mark_dark.png"
117
+ alt="{{config.serviceName}}"
118
+ src="{{config.markUrl}}"
119
119
  style="display:block;outline:none;border:none;text-decoration:none;width:24px"
120
120
  /></td>
121
121
  </tr>
@@ -42,8 +42,8 @@
42
42
  <tbody>
43
43
  <tr>
44
44
  <td><img
45
- alt="Bluesky"
46
- src="https://bsky.social/about/images/email/email_logo_default.png"
45
+ alt="{{config.serviceName}}"
46
+ src="{{config.logoUrl}}"
47
47
  style="display:block;outline:none;border:none;text-decoration:none;width:110px;margin:0 auto"
48
48
  /></td>
49
49
  </tr>
@@ -68,7 +68,7 @@
68
68
  style="font-size:16px;line-height:1.4;margin:0px 0px;letter-spacing:0.25px;color:hsl(211, 24%, 34.2%);font-family:-apple-system, BlinkMacSystemFont, &#x27;Roboto&#x27;, &#x27;Oxygen&#x27;, &#x27;Ubuntu&#x27;, &#x27;Cantarell&#x27;, &#x27;Fira Sans&#x27;, &#x27;Droid Sans&#x27;, &#x27;Helvetica Neue&#x27;, sans-serif;padding-top:12px;padding-bottom:12px"
69
69
  >We received a request to reset the password for the account<!-- -->
70
70
  <span
71
- style="color:hsl(211, 99%, 53%)"
71
+ style="color:{{config.primaryColor}}"
72
72
  >@<!-- -->{{handle}}<!-- -->.</span></p><code
73
73
  style="display:block;padding:16px;border-radius:8px;border-width:1px;border-style:solid;background-color:hsl(211, 20%, 95.3%);border-color:hsl(211, 20%, 85.89999999999999%);font-size:14px;letter-spacing:0.25px;font-family:monospace;text-transform:uppercase"
74
74
  >{{token}}</code>
@@ -106,17 +106,17 @@
106
106
  <p
107
107
  style="font-size:14px;line-height:1.4;margin:0px 0px;color:hsl(211, 20%, 53%);font-family:-apple-system, BlinkMacSystemFont, &#x27;Roboto&#x27;, &#x27;Oxygen&#x27;, &#x27;Ubuntu&#x27;, &#x27;Cantarell&#x27;, &#x27;Fira Sans&#x27;, &#x27;Droid Sans&#x27;, &#x27;Helvetica Neue&#x27;, sans-serif;letter-spacing:0.25px"
108
108
  ><a
109
- href="https://bsky.app"
109
+ href="{{config.homeUrl}}"
110
110
  style="color:hsl(211, 20%, 53%);text-decoration:none;text-decoration-line:underline;font-family:-apple-system, BlinkMacSystemFont, &#x27;Roboto&#x27;, &#x27;Oxygen&#x27;, &#x27;Ubuntu&#x27;, &#x27;Cantarell&#x27;, &#x27;Fira Sans&#x27;, &#x27;Droid Sans&#x27;, &#x27;Helvetica Neue&#x27;, sans-serif;margin:0px 0px;line-height:1.0;font-size:14px;letter-spacing:0.25px"
111
111
  target="_blank"
112
- >Bluesky</a>, the social internet</p>
112
+ >{{config.serviceName}}</a></p>
113
113
  </td>
114
114
  <td
115
115
  data-id="__react-email-column"
116
116
  style="width:24px"
117
117
  ><img
118
- alt="🦋"
119
- src="https://bsky.social/about/images/email/email_mark_dark.png"
118
+ alt="{{config.serviceName}}"
119
+ src="{{config.markUrl}}"
120
120
  style="display:block;outline:none;border:none;text-decoration:none;width:24px"
121
121
  /></td>
122
122
  </tr>
@@ -43,8 +43,8 @@
43
43
  <tbody>
44
44
  <tr>
45
45
  <td><img
46
- alt="Bluesky"
47
- src="https://bsky.social/about/images/email/email_logo_default.png"
46
+ alt="{{config.serviceName}}"
47
+ src="{{config.logoUrl}}"
48
48
  style="display:block;outline:none;border:none;text-decoration:none;width:110px;margin:0 auto"
49
49
  /></td>
50
50
  </tr>
@@ -105,17 +105,17 @@
105
105
  <p
106
106
  style="font-size:14px;line-height:1.4;margin:0px 0px;color:hsl(211, 20%, 53%);font-family:-apple-system, BlinkMacSystemFont, &#x27;Roboto&#x27;, &#x27;Oxygen&#x27;, &#x27;Ubuntu&#x27;, &#x27;Cantarell&#x27;, &#x27;Fira Sans&#x27;, &#x27;Droid Sans&#x27;, &#x27;Helvetica Neue&#x27;, sans-serif;letter-spacing:0.25px"
107
107
  ><a
108
- href="https://bsky.app"
108
+ href="{{config.homeUrl}}"
109
109
  style="color:hsl(211, 20%, 53%);text-decoration:none;text-decoration-line:underline;font-family:-apple-system, BlinkMacSystemFont, &#x27;Roboto&#x27;, &#x27;Oxygen&#x27;, &#x27;Ubuntu&#x27;, &#x27;Cantarell&#x27;, &#x27;Fira Sans&#x27;, &#x27;Droid Sans&#x27;, &#x27;Helvetica Neue&#x27;, sans-serif;margin:0px 0px;line-height:1.0;font-size:14px;letter-spacing:0.25px"
110
110
  target="_blank"
111
- >Bluesky</a>, the social internet</p>
111
+ >{{config.serviceName}}</a></p>
112
112
  </td>
113
113
  <td
114
114
  data-id="__react-email-column"
115
115
  style="width:24px"
116
116
  ><img
117
- alt="🦋"
118
- src="https://bsky.social/about/images/email/email_mark_dark.png"
117
+ alt="{{config.serviceName}}"
118
+ src="{{config.markUrl}}"
119
119
  style="display:block;outline:none;border:none;text-decoration:none;width:24px"
120
120
  /></td>
121
121
  </tr>
@@ -3,3 +3,15 @@ export { default as deleteAccount } from './templates/delete-account.js'
3
3
  export { default as confirmEmail } from './templates/confirm-email.js'
4
4
  export { default as updateEmail } from './templates/update-email.js'
5
5
  export { default as plcOperation } from './templates/plc-operation.js'
6
+
7
+ /**
8
+ * Common config variable that will be injected into all templates.
9
+ */
10
+ export type Config = {
11
+ serviceName: string
12
+ homeUrl: string
13
+ logoUrl: string
14
+ markUrl: string
15
+ primaryColor: string
16
+ showBskyAppEmailConfirmationLink: boolean
17
+ }
@@ -56,9 +56,23 @@ export const proxyHandler = (ctx: AppContext): CatchallHandler => {
56
56
  throw new InvalidRequestError('Bad token method', 'InvalidToken')
57
57
  }
58
58
 
59
- const { url: origin, did: aud } = await parseProxyInfo(ctx, req, lxm)
60
-
61
- const authResult = await performAuth({ req, res, params: { lxm, aud } })
59
+ const {
60
+ url: origin,
61
+ did,
62
+ serviceId,
63
+ } = await parseProxyInfo(ctx, req, lxm)
64
+ // Phase 1 of service auth updates: the scope check sees the combined
65
+ // did#serviceId form (so OAuth callers' rpc:?aud=did#service scopes
66
+ // match), while the outbound service-auth JWT keeps bare-DID aud
67
+ // regardless of session type.
68
+ const scopeAud = `${did}#${serviceId}`
69
+ const tokenAud = did
70
+
71
+ const authResult = await performAuth({
72
+ req,
73
+ res,
74
+ params: { lxm, aud: scopeAud },
75
+ })
62
76
 
63
77
  const { credentials } = excludeErrorResult(authResult)
64
78
 
@@ -80,7 +94,7 @@ export const proxyHandler = (ctx: AppContext): CatchallHandler => {
80
94
  'content-encoding': body && req.headers['content-encoding'],
81
95
  'content-length': body && req.headers['content-length'],
82
96
 
83
- authorization: `Bearer ${await ctx.serviceAuthJwt(credentials.did, aud, lxm)}`,
97
+ authorization: `Bearer ${await ctx.serviceAuthJwt(credentials.did, tokenAud, lxm)}`,
84
98
  }
85
99
 
86
100
  const dispatchOptions: Dispatcher.RequestOptions = {
@@ -216,18 +230,25 @@ export function computeProxyTo(
216
230
  throw new InvalidRequestError(`No service configured for ${lxm}`)
217
231
  }
218
232
 
233
+ // Bare-DID portion of `proxyTo`, suitable as a service-auth JWT audience
234
+ // (Phase 1 of service auth updates).
235
+ export function bareDidFromProxyTo(proxyTo: string): string {
236
+ const hashIndex = proxyTo.indexOf('#')
237
+ return hashIndex === -1 ? proxyTo : proxyTo.slice(0, hashIndex)
238
+ }
239
+
219
240
  export async function parseProxyInfo(
220
241
  ctx: AppContext,
221
242
  req: Request,
222
243
  lxm: string,
223
- ): Promise<{ url: string; did: string }> {
244
+ ): Promise<{ url: string; did: string; serviceId: string }> {
224
245
  // /!\ Hot path
225
246
 
226
247
  const proxyToHeader = req.header('atproto-proxy')
227
248
  if (proxyToHeader) return parseProxyHeader(ctx, proxyToHeader)
228
249
 
229
- const { serviceInfo } = defaultService(ctx, lxm)
230
- if (serviceInfo) return serviceInfo
250
+ const { serviceId, serviceInfo } = defaultService(ctx, lxm)
251
+ if (serviceInfo) return { ...serviceInfo, serviceId }
231
252
 
232
253
  throw new InvalidRequestError(`No service configured for ${lxm}`)
233
254
  }
@@ -236,7 +257,7 @@ export const parseProxyHeader = async (
236
257
  // Using subset of AppContext for testing purposes
237
258
  ctx: Pick<AppContext, 'cfg' | 'idResolver'>,
238
259
  proxyTo: string,
239
- ): Promise<{ did: string; url: string }> => {
260
+ ): Promise<{ did: string; url: string; serviceId: string }> => {
240
261
  // /!\ Hot path
241
262
 
242
263
  const hashIndex = proxyTo.indexOf('#')
@@ -260,13 +281,14 @@ export const parseProxyHeader = async (
260
281
  }
261
282
 
262
283
  const did = proxyTo.slice(0, hashIndex)
284
+ const serviceId = proxyTo.slice(hashIndex + 1)
263
285
 
264
286
  // Special case a configured appview, while still proxying correctly any other appview
265
287
  if (
266
288
  ctx.cfg.bskyAppView &&
267
289
  proxyTo === `${ctx.cfg.bskyAppView.did}#bsky_appview`
268
290
  ) {
269
- return { did, url: ctx.cfg.bskyAppView.url }
291
+ return { did, url: ctx.cfg.bskyAppView.url, serviceId }
270
292
  }
271
293
 
272
294
  const didDoc = await ctx.idResolver.did.resolve(did)
@@ -274,13 +296,12 @@ export const parseProxyHeader = async (
274
296
  throw new InvalidRequestError('could not resolve proxy did')
275
297
  }
276
298
 
277
- const serviceId = proxyTo.slice(hashIndex)
278
- const url = getServiceEndpoint(didDoc, { id: serviceId })
299
+ const url = getServiceEndpoint(didDoc, { id: `#${serviceId}` })
279
300
  if (!url) {
280
301
  throw new InvalidRequestError('could not resolve proxy did service url')
281
302
  }
282
303
 
283
- return { did, url }
304
+ return { did, url, serviceId }
284
305
  }
285
306
 
286
307
  /**
@@ -115,7 +115,7 @@ describe('app_passwords', () => {
115
115
  it('restricts service auth token methods for non-privileged access tokens', async () => {
116
116
  const attemptCaseSensitive = appAgent.api.com.atproto.server.getServiceAuth(
117
117
  {
118
- aud: 'did:example:test',
118
+ aud: network.pds.ctx.cfg.service.did,
119
119
  lxm: 'com.atproto.server.createAccount',
120
120
  },
121
121
  )
@@ -124,7 +124,7 @@ describe('app_passwords', () => {
124
124
  )
125
125
  const attemptCaseInsensitive =
126
126
  appAgent.api.com.atproto.server.getServiceAuth({
127
- aud: 'did:example:test',
127
+ aud: network.pds.ctx.cfg.service.did,
128
128
  lxm: 'com.atproto.server.createaccount',
129
129
  })
130
130
  await expect(attemptCaseInsensitive).rejects.toThrow(
@@ -134,7 +134,7 @@ describe('app_passwords', () => {
134
134
 
135
135
  it('allows privileged service auth token scopes for privileged access tokens', async () => {
136
136
  await priviAgent.api.com.atproto.server.getServiceAuth({
137
- aud: 'did:example:test',
137
+ aud: network.pds.ctx.cfg.service.did,
138
138
  lxm: 'com.atproto.server.createAccount',
139
139
  })
140
140
  })
@@ -165,7 +165,7 @@ describe('app_passwords', () => {
165
165
 
166
166
  // allows privileged app passwords or higher
167
167
  const priviAttempt = appAgent.api.com.atproto.server.getServiceAuth({
168
- aud: 'did:example:test',
168
+ aud: network.pds.ctx.cfg.service.did,
169
169
  lxm: 'com.atproto.server.createAccount',
170
170
  })
171
171
  await expect(priviAttempt).rejects.toThrow(
@@ -211,7 +211,7 @@ describe('app_passwords', () => {
211
211
 
212
212
  // allows privileged app passwords or higher
213
213
  await priviAgent.api.com.atproto.server.getServiceAuth({
214
- aud: 'did:example:test',
214
+ aud: network.pds.ctx.cfg.service.did,
215
215
  })
216
216
 
217
217
  // allows only full access auth
@@ -0,0 +1,81 @@
1
+ import * as jose from 'jose'
2
+ import { AtpAgent } from '@atproto/api'
3
+ import { TestNetworkNoAppView } from '@atproto/dev-env'
4
+
5
+ describe('com.atproto.server.getServiceAuth', () => {
6
+ let network: TestNetworkNoAppView
7
+ let agent: AtpAgent
8
+ let aliceDid: string
9
+ let pdsDid: string
10
+
11
+ beforeAll(async () => {
12
+ network = await TestNetworkNoAppView.create({
13
+ dbPostgresSchema: 'get_service_auth',
14
+ })
15
+ pdsDid = network.pds.ctx.cfg.service.did
16
+ agent = network.pds.getAgent()
17
+ const session = await agent.createAccount({
18
+ handle: 'alice.test',
19
+ email: 'alice@test.com',
20
+ password: 'alice-pass',
21
+ })
22
+ aliceDid = session.data.did
23
+ })
24
+
25
+ afterAll(async () => {
26
+ await network.close()
27
+ })
28
+
29
+ it('issues a token whose aud matches a bare-DID input', async () => {
30
+ const res = await agent.api.com.atproto.server.getServiceAuth({
31
+ aud: pdsDid,
32
+ lxm: 'com.atproto.server.describeServer',
33
+ })
34
+ const decoded = jose.decodeJwt(res.data.token)
35
+ expect(decoded.aud).toBe(pdsDid)
36
+ expect(decoded.iss).toBe(aliceDid)
37
+ expect(decoded.lxm).toBe('com.atproto.server.describeServer')
38
+ })
39
+
40
+ it('issues a token whose aud matches a combined did#serviceId input', async () => {
41
+ const aud = `${pdsDid}#atproto_pds`
42
+ const res = await agent.api.com.atproto.server.getServiceAuth({
43
+ aud,
44
+ lxm: 'com.atproto.server.describeServer',
45
+ })
46
+ const decoded = jose.decodeJwt(res.data.token)
47
+ expect(decoded.aud).toBe(aud)
48
+ expect(decoded.iss).toBe(aliceDid)
49
+ expect(decoded.lxm).toBe('com.atproto.server.describeServer')
50
+ })
51
+
52
+ it('rejects malformed aud with InvalidRequest', async () => {
53
+ const attempt = agent.api.com.atproto.server.getServiceAuth({
54
+ aud: 'not-a-did',
55
+ lxm: 'com.atproto.server.describeServer',
56
+ })
57
+ await expect(attempt).rejects.toThrow(
58
+ /aud must be a valid atproto DID or did#serviceId reference/,
59
+ )
60
+ })
61
+
62
+ it('rejects an aud with a non-atproto DID method', async () => {
63
+ const attempt = agent.api.com.atproto.server.getServiceAuth({
64
+ aud: 'did:foo:bar',
65
+ lxm: 'com.atproto.server.describeServer',
66
+ })
67
+ await expect(attempt).rejects.toThrow(
68
+ /aud must be a valid atproto DID or did#serviceId reference/,
69
+ )
70
+ })
71
+
72
+ it('rejects an aud with empty fragment', async () => {
73
+ const attempt = agent.api.com.atproto.server.getServiceAuth({
74
+ aud: `${pdsDid}#`,
75
+ lxm: 'com.atproto.server.describeServer',
76
+ })
77
+ await expect(attempt).rejects.toThrow(
78
+ /aud must be a valid atproto DID or did#serviceId reference/,
79
+ )
80
+ })
81
+ })
@@ -77,6 +77,7 @@ describe('proxy header', () => {
77
77
  ).resolves.toEqual({
78
78
  did: proxyServer.did,
79
79
  url: proxyServer.url,
80
+ serviceId: 'atproto_test',
80
81
  })
81
82
  })
82
83
 
@@ -0,0 +1,175 @@
1
+ import { once } from 'node:events'
2
+ import http from 'node:http'
3
+ import { AddressInfo } from 'node:net'
4
+ import * as plc from '@did-plc/lib'
5
+ import express from 'express'
6
+ import { Keypair } from '@atproto/crypto'
7
+ import { SeedClient, TestNetworkNoAppView, usersSeed } from '@atproto/dev-env'
8
+ import { ScopePermissions } from '@atproto/oauth-scopes'
9
+ import { DidString } from '@atproto/syntax'
10
+ import { AppContext } from '../../src/context.js'
11
+ import { proxyHandler } from '../../src/pipethrough.js'
12
+
13
+ // Regression test for the OAuth service-proxying audience fix.
14
+ //
15
+ // Before the fix, proxyHandler passed a bare DID as `aud` to the rpc scope
16
+ // check. An OAuth caller granted `rpc:<lxm>?aud=<did>#<serviceId>` (the
17
+ // canonical scope shape used in atproto OAuth) had no way to match, so
18
+ // proxied calls failed at the scope check. The fix combines the proxied
19
+ // service id into the scope-check audience as `<did>#<serviceId>`.
20
+ //
21
+ // We use a real PDS AppContext (so every field is real-typed). Only the
22
+ // `authVerifier.authorization` method is replaced, with a thin stub that
23
+ // drives the OAuth path off an `x-test-scope` request header — letting us
24
+ // exercise the rpc scope check without minting a real OAuth token. A
25
+ // separate express app mounts a fresh proxyHandler against the real ctx
26
+ // and forwards to a real upstream ProxyServer.
27
+
28
+ describe('proxy oauth audience', () => {
29
+ let network: TestNetworkNoAppView
30
+ let sc: SeedClient
31
+ let alice: string
32
+ let upstream: ProxyServer
33
+ let server: http.Server
34
+ let serverUrl: string
35
+
36
+ beforeAll(async () => {
37
+ network = await TestNetworkNoAppView.create({
38
+ dbPostgresSchema: 'proxy_oauth_aud',
39
+ })
40
+ sc = network.getSeedClient()
41
+ await usersSeed(sc)
42
+ alice = sc.dids.alice
43
+ upstream = await ProxyServer.create(
44
+ network.pds.ctx.plcClient,
45
+ network.pds.ctx.plcRotationKey,
46
+ 'atproto_test',
47
+ )
48
+
49
+ // Replace only the `authorization` method on the real AuthVerifier so
50
+ // every other ctx field stays real and real-typed. The override's
51
+ // signature is constrained by AuthVerifier['authorization']: an OAuth
52
+ // shape change in the real verifier breaks the body of this stub at
53
+ // compile time.
54
+ const stubAuthorization: typeof network.pds.ctx.authVerifier.authorization =
55
+ ({ authorize }) => {
56
+ return async (ctx) => {
57
+ const scopeHeader = ctx.req.headers['x-test-scope'] as
58
+ | string
59
+ | undefined
60
+ const permissions = new ScopePermissions(
61
+ scopeHeader?.split(' ') ?? [],
62
+ )
63
+ await authorize(permissions, ctx)
64
+ return {
65
+ credentials: {
66
+ type: 'oauth',
67
+ did: alice as DidString,
68
+ permissions,
69
+ },
70
+ }
71
+ }
72
+ }
73
+ network.pds.ctx.authVerifier.authorization = stubAuthorization
74
+
75
+ const app = express()
76
+ // dev-env exports AppContext from its built dist/, while we import
77
+ // proxyHandler from src/. Cast at this boundary to bridge the two
78
+ // identical shapes; downstream behavior is real.
79
+ app.all('/xrpc/*', proxyHandler(network.pds.ctx as unknown as AppContext))
80
+ app.use(
81
+ (
82
+ err: Error & { status?: number; statusCode?: number },
83
+ _req: express.Request,
84
+ res: express.Response,
85
+ _next: express.NextFunction,
86
+ ) => {
87
+ if (res.headersSent) return
88
+ res.status(err.status ?? err.statusCode ?? 500).end()
89
+ },
90
+ )
91
+ server = app.listen(0)
92
+ await once(server, 'listening')
93
+ serverUrl = `http://localhost:${(server.address() as AddressInfo).port}`
94
+ })
95
+
96
+ afterAll(async () => {
97
+ await new Promise<void>((resolve) => server.close(() => resolve()))
98
+ await upstream.close()
99
+ await network.close()
100
+ })
101
+
102
+ it('matches an OAuth rpc scope granted with combined did#serviceId aud', async () => {
103
+ // Pre-fix this would have rejected because the scope check received
104
+ // bare-DID aud and never matched the combined-form scope. A 200 here
105
+ // implies the scope check ran with combined-form aud.
106
+ const res = await fetch(`${serverUrl}/xrpc/app.bsky.feed.getFeed`, {
107
+ headers: {
108
+ 'atproto-proxy': `${upstream.did}#atproto_test`,
109
+ 'x-test-scope': `rpc:app.bsky.feed.getFeed?aud=${encodeURIComponent(`${upstream.did}#atproto_test`)}`,
110
+ },
111
+ })
112
+ expect(res.status).toBe(200)
113
+ })
114
+
115
+ it('rejects an OAuth rpc scope granted for a different service id', async () => {
116
+ // Same DID, different service id — both forms parse as valid scope
117
+ // audiences, but the runtime aud (`upstream.did#atproto_test`) doesn't
118
+ // match the granted aud (`upstream.did#atproto_other`).
119
+ const res = await fetch(`${serverUrl}/xrpc/app.bsky.feed.getFeed`, {
120
+ headers: {
121
+ 'atproto-proxy': `${upstream.did}#atproto_test`,
122
+ 'x-test-scope': `rpc:app.bsky.feed.getFeed?aud=${encodeURIComponent(`${upstream.did}#atproto_other`)}`,
123
+ },
124
+ })
125
+ expect([401, 403]).toContain(res.status)
126
+ })
127
+ })
128
+
129
+ class ProxyServer {
130
+ constructor(
131
+ public server: http.Server,
132
+ public url: string,
133
+ public did: string,
134
+ ) {}
135
+
136
+ static async create(
137
+ plcClient: plc.Client,
138
+ keypair: Keypair,
139
+ serviceId: string,
140
+ ): Promise<ProxyServer> {
141
+ const app = express()
142
+ app.all('*', (_req, res) => res.sendStatus(200))
143
+
144
+ const server = app.listen(0)
145
+ await once(server, 'listening')
146
+
147
+ const { port } = server.address() as AddressInfo
148
+ const url = `http://localhost:${port}`
149
+ const plcOp = await plc.signOperation(
150
+ {
151
+ type: 'plc_operation',
152
+ rotationKeys: [keypair.did()],
153
+ alsoKnownAs: [],
154
+ verificationMethods: {},
155
+ services: {
156
+ [serviceId]: {
157
+ type: 'TestAtprotoService',
158
+ endpoint: url,
159
+ },
160
+ },
161
+ prev: null,
162
+ },
163
+ keypair,
164
+ )
165
+ const did = await plc.didForCreateOp(plcOp)
166
+ await plcClient.sendOperation(did, plcOp)
167
+ return new ProxyServer(server, url, did)
168
+ }
169
+
170
+ close(): Promise<void> {
171
+ return new Promise<void>((resolve) => {
172
+ this.server.close(() => resolve())
173
+ })
174
+ }
175
+ }