@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.
- package/CHANGELOG.md +16 -0
- package/dist/api/app/bsky/actor/getPreferences.d.ts.map +1 -1
- package/dist/api/app/bsky/actor/getPreferences.js +7 -2
- package/dist/api/app/bsky/actor/getPreferences.js.map +1 -1
- package/dist/api/app/bsky/actor/putPreferences.d.ts.map +1 -1
- package/dist/api/app/bsky/actor/putPreferences.js +7 -2
- package/dist/api/app/bsky/actor/putPreferences.js.map +1 -1
- package/dist/api/com/atproto/server/getServiceAuth.d.ts.map +1 -1
- package/dist/api/com/atproto/server/getServiceAuth.js +4 -0
- package/dist/api/com/atproto/server/getServiceAuth.js.map +1 -1
- package/dist/config/config.d.ts +5 -2
- package/dist/config/config.d.ts.map +1 -1
- package/dist/config/config.js +50 -46
- package/dist/config/config.js.map +1 -1
- package/dist/config/env.d.ts +1 -0
- package/dist/config/env.d.ts.map +1 -1
- package/dist/config/env.js +1 -0
- package/dist/config/env.js.map +1 -1
- package/dist/context.js +1 -1
- package/dist/context.js.map +1 -1
- package/dist/lexicons/chat/bsky/actor/getStatus.defs.d.ts +2 -0
- package/dist/lexicons/chat/bsky/actor/getStatus.defs.d.ts.map +1 -1
- package/dist/lexicons/chat/bsky/actor/getStatus.defs.js +1 -0
- package/dist/lexicons/chat/bsky/actor/getStatus.defs.js.map +1 -1
- package/dist/lexicons/chat/bsky/convo/defs.defs.d.ts +1 -1
- package/dist/lexicons/chat/bsky/convo/defs.defs.d.ts.map +1 -1
- package/dist/lexicons/chat/bsky/convo/defs.defs.js +1 -1
- package/dist/lexicons/chat/bsky/convo/defs.defs.js.map +1 -1
- package/dist/lexicons/com/atproto/server/getServiceAuth.defs.d.ts +2 -2
- package/dist/lexicons/com/atproto/server/getServiceAuth.defs.js +1 -1
- package/dist/lexicons/com/atproto/server/getServiceAuth.defs.js.map +1 -1
- package/dist/mailer/index.d.ts +3 -3
- package/dist/mailer/index.d.ts.map +1 -1
- package/dist/mailer/index.js +18 -9
- package/dist/mailer/index.js.map +1 -1
- package/dist/mailer/templates/confirm-email.js +11 -3
- package/dist/mailer/templates/confirm-email.js.map +2 -2
- package/dist/mailer/templates/delete-account.js +2 -2
- package/dist/mailer/templates/delete-account.js.map +2 -2
- package/dist/mailer/templates/plc-operation.js +2 -2
- package/dist/mailer/templates/plc-operation.js.map +2 -2
- package/dist/mailer/templates/reset-password.js +2 -2
- package/dist/mailer/templates/reset-password.js.map +2 -2
- package/dist/mailer/templates/update-email.js +2 -2
- package/dist/mailer/templates/update-email.js.map +2 -2
- package/dist/mailer/templates.d.ts +11 -0
- package/dist/mailer/templates.d.ts.map +1 -1
- package/dist/mailer/templates.js.map +1 -1
- package/dist/pipethrough.d.ts +3 -0
- package/dist/pipethrough.d.ts.map +1 -1
- package/dist/pipethrough.js +25 -9
- package/dist/pipethrough.js.map +1 -1
- package/package.json +11 -10
- package/src/api/app/bsky/actor/getPreferences.ts +11 -2
- package/src/api/app/bsky/actor/putPreferences.ts +11 -2
- package/src/api/com/atproto/server/getServiceAuth.ts +7 -0
- package/src/config/config.ts +69 -57
- package/src/config/env.ts +3 -0
- package/src/context.ts +1 -1
- package/src/mailer/index.ts +25 -9
- package/src/mailer/templates/confirm-email.hbs +18 -17
- package/src/mailer/templates/delete-account.hbs +6 -6
- package/src/mailer/templates/plc-operation.hbs +6 -6
- package/src/mailer/templates/reset-password.hbs +7 -7
- package/src/mailer/templates/update-email.hbs +6 -6
- package/src/mailer/templates.ts +12 -0
- package/src/pipethrough.ts +33 -12
- package/tests/app-passwords.test.ts +5 -5
- package/tests/get-service-auth.test.ts +81 -0
- package/tests/proxied/proxy-header.test.ts +1 -0
- 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
|
-
>
|
|
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="
|
|
46
|
-
src="
|
|
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, 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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, 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;letter-spacing:0.25px"
|
|
111
112
|
><a
|
|
112
|
-
href="
|
|
113
|
+
href="{{config.homeUrl}}"
|
|
113
114
|
style="color:hsl(211, 20%, 53%);text-decoration:none;text-decoration-line:underline;font-family:-apple-system, BlinkMacSystemFont, 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;margin:0px 0px;line-height:1.0;font-size:14px;letter-spacing:0.25px"
|
|
114
115
|
target="_blank"
|
|
115
|
-
>
|
|
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="
|
|
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="
|
|
47
|
-
src="
|
|
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, 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;letter-spacing:0.25px"
|
|
110
110
|
><a
|
|
111
|
-
href="
|
|
111
|
+
href="{{config.homeUrl}}"
|
|
112
112
|
style="color:hsl(211, 20%, 53%);text-decoration:none;text-decoration-line:underline;font-family:-apple-system, BlinkMacSystemFont, 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;margin:0px 0px;line-height:1.0;font-size:14px;letter-spacing:0.25px"
|
|
113
113
|
target="_blank"
|
|
114
|
-
>
|
|
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="
|
|
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="
|
|
46
|
-
src="
|
|
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, 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;letter-spacing:0.25px"
|
|
107
107
|
><a
|
|
108
|
-
href="
|
|
108
|
+
href="{{config.homeUrl}}"
|
|
109
109
|
style="color:hsl(211, 20%, 53%);text-decoration:none;text-decoration-line:underline;font-family:-apple-system, BlinkMacSystemFont, 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;margin:0px 0px;line-height:1.0;font-size:14px;letter-spacing:0.25px"
|
|
110
110
|
target="_blank"
|
|
111
|
-
>
|
|
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="
|
|
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="
|
|
46
|
-
src="
|
|
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, 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 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:
|
|
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, 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;letter-spacing:0.25px"
|
|
108
108
|
><a
|
|
109
|
-
href="
|
|
109
|
+
href="{{config.homeUrl}}"
|
|
110
110
|
style="color:hsl(211, 20%, 53%);text-decoration:none;text-decoration-line:underline;font-family:-apple-system, BlinkMacSystemFont, 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;margin:0px 0px;line-height:1.0;font-size:14px;letter-spacing:0.25px"
|
|
111
111
|
target="_blank"
|
|
112
|
-
>
|
|
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="
|
|
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="
|
|
47
|
-
src="
|
|
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, 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;letter-spacing:0.25px"
|
|
107
107
|
><a
|
|
108
|
-
href="
|
|
108
|
+
href="{{config.homeUrl}}"
|
|
109
109
|
style="color:hsl(211, 20%, 53%);text-decoration:none;text-decoration-line:underline;font-family:-apple-system, BlinkMacSystemFont, 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;margin:0px 0px;line-height:1.0;font-size:14px;letter-spacing:0.25px"
|
|
110
110
|
target="_blank"
|
|
111
|
-
>
|
|
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="
|
|
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>
|
package/src/mailer/templates.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/pipethrough.ts
CHANGED
|
@@ -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 {
|
|
60
|
-
|
|
61
|
-
|
|
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,
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
+
})
|
|
@@ -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
|
+
}
|