latchkey 3.10.1 → 3.11.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/dist/src/curlInjection.d.ts +10 -2
- package/dist/src/curlInjection.d.ts.map +1 -1
- package/dist/src/curlInjection.js +42 -27
- package/dist/src/curlInjection.js.map +1 -1
- package/dist/src/errorMessages.d.ts +1 -0
- package/dist/src/errorMessages.d.ts.map +1 -1
- package/dist/src/errorMessages.js +2 -0
- package/dist/src/errorMessages.js.map +1 -1
- package/dist/src/gateway/gatewayEndpoint.d.ts +11 -0
- package/dist/src/gateway/gatewayEndpoint.d.ts.map +1 -1
- package/dist/src/gateway/gatewayEndpoint.js +61 -16
- package/dist/src/gateway/gatewayEndpoint.js.map +1 -1
- package/dist/src/services/core/base.d.ts +17 -0
- package/dist/src/services/core/base.d.ts.map +1 -1
- package/dist/src/services/core/base.js +20 -0
- package/dist/src/services/core/base.js.map +1 -1
- package/dist/src/services/core/loginFlows/tokenCapture.d.ts +9 -2
- package/dist/src/services/core/loginFlows/tokenCapture.d.ts.map +1 -1
- package/dist/src/services/core/loginFlows/tokenCapture.js +103 -15
- package/dist/src/services/core/loginFlows/tokenCapture.js.map +1 -1
- package/dist/src/services/slack.d.ts +35 -3
- package/dist/src/services/slack.d.ts.map +1 -1
- package/dist/src/services/slack.js +112 -21
- package/dist/src/services/slack.js.map +1 -1
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/dist/tests/gateway.test.js +95 -0
- package/dist/tests/gateway.test.js.map +1 -1
- package/dist/tests/slack.test.d.ts +6 -0
- package/dist/tests/slack.test.d.ts.map +1 -0
- package/dist/tests/slack.test.js +159 -0
- package/dist/tests/slack.test.js.map +1 -0
- package/dist/tests/tokenCapture.test.js +103 -2
- package/dist/tests/tokenCapture.test.js.map +1 -1
- package/package.json +4 -1
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* A generic browser login for services whose credential is a token the web app
|
|
3
|
-
* mints for itself: open a URL,
|
|
4
|
-
*
|
|
3
|
+
* mints for itself: open a URL, and lift a token out of the JSON body of a
|
|
4
|
+
* named endpoint, either as it goes by or by asking for it.
|
|
5
|
+
*
|
|
6
|
+
* Watching alone is not enough, because a web app calls its own mint endpoint
|
|
7
|
+
* when it needs a token and not otherwise. ChatGPT is the case that showed
|
|
8
|
+
* this: it signs in, renders the app, and never calls `/api/auth/session` —
|
|
9
|
+
* the endpoint is there and answers with a token, but a login that only
|
|
10
|
+
* listens waits for a request that is never made. So once the browser reaches
|
|
11
|
+
* the endpoint's origin, this flow also asks it directly.
|
|
5
12
|
*
|
|
6
13
|
* This is the counterpart to `cookie-capture`, for the other common shape. In a
|
|
7
14
|
* cookie-authenticated service the cookie *is* the API credential, so capturing
|
|
@@ -36,6 +43,12 @@ const TOKEN_PLACEHOLDER = '{token}';
|
|
|
36
43
|
* Bearer is what the great majority of token-minting web APIs expect.
|
|
37
44
|
*/
|
|
38
45
|
const DEFAULT_HEADER_TEMPLATE = `Authorization: Bearer ${TOKEN_PLACEHOLDER}`;
|
|
46
|
+
/**
|
|
47
|
+
* How often the endpoint is asked, once the browser is somewhere it can be
|
|
48
|
+
* asked from. Slow enough not to hammer a service through a login that takes a
|
|
49
|
+
* while, quick enough that the login ends promptly once a token exists.
|
|
50
|
+
*/
|
|
51
|
+
const TOKEN_REQUEST_INTERVAL_MS = 2_000;
|
|
39
52
|
/**
|
|
40
53
|
* Identity of an endpoint for matching purposes: origin and path, with the
|
|
41
54
|
* query string and fragment dropped.
|
|
@@ -101,24 +114,53 @@ const TokenCaptureParamsSchema = z
|
|
|
101
114
|
.optional(),
|
|
102
115
|
})
|
|
103
116
|
.strict();
|
|
117
|
+
/**
|
|
118
|
+
* Ask the endpoint from inside the page, so the request is the browser's own:
|
|
119
|
+
* same cookies, same bot-detection clearance the app itself enjoys. Returns
|
|
120
|
+
* null when the endpoint refuses the request.
|
|
121
|
+
*/
|
|
122
|
+
function requestTokenBody(page, tokenUrl) {
|
|
123
|
+
return page.evaluate(async (url) => {
|
|
124
|
+
const response = await fetch(url, { credentials: 'include' });
|
|
125
|
+
return response.ok ? await response.text() : null;
|
|
126
|
+
}, tokenUrl);
|
|
127
|
+
}
|
|
104
128
|
/** Store the captured token the way `latchkey auth set -H` would. */
|
|
105
129
|
function buildTokenCredentials(headerTemplate, token) {
|
|
106
130
|
return new RawCurlCredentials(['-H', headerTemplate.replaceAll(TOKEN_PLACEHOLDER, token)]);
|
|
107
131
|
}
|
|
108
132
|
/**
|
|
109
|
-
* One run of the flow
|
|
110
|
-
*
|
|
133
|
+
* One run of the flow. The endpoint is both watched and asked, and whichever
|
|
134
|
+
* produces a token first ends the login.
|
|
111
135
|
*/
|
|
112
136
|
class TokenCaptureSession extends SimpleServiceSession {
|
|
137
|
+
tokenUrl;
|
|
113
138
|
tokenEndpointIdentity;
|
|
114
139
|
tokenPath;
|
|
115
140
|
headerTemplate;
|
|
141
|
+
lastRequestedAt = 0;
|
|
116
142
|
constructor(service, appNamePrefix, tokenUrl, tokenPath, headerTemplate) {
|
|
117
143
|
super(service, appNamePrefix);
|
|
144
|
+
this.tokenUrl = tokenUrl;
|
|
118
145
|
this.tokenEndpointIdentity = endpointIdentity(tokenUrl);
|
|
119
146
|
this.tokenPath = tokenPath;
|
|
120
147
|
this.headerTemplate = headerTemplate;
|
|
121
148
|
}
|
|
149
|
+
/**
|
|
150
|
+
* Credentials from one body of the endpoint, or null when it carries no
|
|
151
|
+
* token — including when it is not JSON at all, which is how a bot-detection
|
|
152
|
+
* interstitial or an error page arrives.
|
|
153
|
+
*/
|
|
154
|
+
credentialsFromBody(rawBody) {
|
|
155
|
+
let token;
|
|
156
|
+
try {
|
|
157
|
+
token = readTokenAtPath(JSON.parse(rawBody), this.tokenPath);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
return token === null ? null : buildTokenCredentials(this.headerTemplate, token);
|
|
163
|
+
}
|
|
122
164
|
async getApiCredentialsFromResponse(response) {
|
|
123
165
|
let responseUrl;
|
|
124
166
|
try {
|
|
@@ -131,17 +173,61 @@ class TokenCaptureSession extends SimpleServiceSession {
|
|
|
131
173
|
if (endpointIdentity(responseUrl) !== this.tokenEndpointIdentity) {
|
|
132
174
|
return null;
|
|
133
175
|
}
|
|
134
|
-
let
|
|
176
|
+
let rawBody;
|
|
135
177
|
try {
|
|
136
|
-
|
|
178
|
+
rawBody = await response.text();
|
|
137
179
|
}
|
|
138
180
|
catch {
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
181
|
+
// The body is no longer retrievable — a redirect, or a response the page
|
|
182
|
+
// navigated away from. Keep waiting rather than fail the login; the next
|
|
183
|
+
// request to the endpoint, ours or the app's, gets another chance.
|
|
142
184
|
return null;
|
|
143
185
|
}
|
|
144
|
-
return
|
|
186
|
+
return this.credentialsFromBody(rawBody);
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Ask the endpoint ourselves, because an app only calls it when it wants a
|
|
190
|
+
* token and may never want one while we are watching.
|
|
191
|
+
*
|
|
192
|
+
* This waits for the browser to arrive on the endpoint's own origin. The
|
|
193
|
+
* request is made from inside the page so that it carries the session, and a
|
|
194
|
+
* cross-origin read of the response would be refused — mid-login the browser
|
|
195
|
+
* is commonly parked on an identity provider, where there is nothing to ask.
|
|
196
|
+
*/
|
|
197
|
+
async whileWaitingForLogin(page) {
|
|
198
|
+
if (Date.now() - this.lastRequestedAt < TOKEN_REQUEST_INTERVAL_MS) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
let pageUrl;
|
|
202
|
+
try {
|
|
203
|
+
pageUrl = new URL(page.url());
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (pageUrl.origin !== this.tokenUrl.origin) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
this.lastRequestedAt = Date.now();
|
|
212
|
+
let rawBody;
|
|
213
|
+
try {
|
|
214
|
+
rawBody = await requestTokenBody(page, this.tokenUrl.href);
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
// The page navigated out from under the request, or the endpoint could
|
|
218
|
+
// not be reached. The next poll asks again; a browser the user closed is
|
|
219
|
+
// caught by the wait loop itself.
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (rawBody === null) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const credentials = this.credentialsFromBody(rawBody);
|
|
226
|
+
// A response may have produced credentials while this request was in
|
|
227
|
+
// flight, and the first token captured is the one the login keeps.
|
|
228
|
+
if (credentials !== null && this.apiCredentials === null) {
|
|
229
|
+
this.apiCredentials = credentials;
|
|
230
|
+
}
|
|
145
231
|
}
|
|
146
232
|
}
|
|
147
233
|
/**
|
|
@@ -168,11 +254,13 @@ export class TokenCaptureLoginFlow {
|
|
|
168
254
|
'and the page calls an endpoint of its own to mint a short-lived token. Use',
|
|
169
255
|
'cookie-capture instead when the cookie is itself the API credential.',
|
|
170
256
|
'',
|
|
171
|
-
'The
|
|
172
|
-
'
|
|
173
|
-
'
|
|
174
|
-
'
|
|
175
|
-
'
|
|
257
|
+
'The endpoint is both watched and asked: once the browser reaches its',
|
|
258
|
+
'origin, it is requested from inside the page every couple of seconds, so a',
|
|
259
|
+
'web app that never calls it unprompted still works. Either way the login',
|
|
260
|
+
'finishes when it answers with a non-empty token, not when it is merely',
|
|
261
|
+
'requested: a signed-out visitor commonly gets the same endpoint answering',
|
|
262
|
+
'`{}`. A token minted this way expires and cannot be refreshed — the refresh',
|
|
263
|
+
'material stays in the browser — so expect to sign in again periodically.',
|
|
176
264
|
'',
|
|
177
265
|
'Example:',
|
|
178
266
|
' $ latchkey services register my-app \\',
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tokenCapture.js","sourceRoot":"","sources":["../../../../../src/services/core/loginFlows/tokenCapture.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"tokenCapture.js","sourceRoot":"","sources":["../../../../../src/services/core/loginFlows/tokenCapture.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Slack service implementation.
|
|
3
3
|
*/
|
|
4
|
-
import type { Response } from 'playwright';
|
|
4
|
+
import type { Page, Response } from 'playwright';
|
|
5
5
|
import { z } from 'zod';
|
|
6
6
|
import type { ApiCredentials } from '../apiCredentials/base.js';
|
|
7
|
-
import { Service, SimpleServiceSession } from './core/base.js';
|
|
7
|
+
import { LoginFailedError, Service, SimpleServiceSession } from './core/base.js';
|
|
8
8
|
/**
|
|
9
9
|
* Slack-specific credentials (token + d cookie).
|
|
10
10
|
*/
|
|
@@ -32,9 +32,41 @@ export declare class SlackApiCredentials implements ApiCredentials {
|
|
|
32
32
|
toJSON(): SlackApiCredentialsData;
|
|
33
33
|
static fromJSON(data: SlackApiCredentialsData): SlackApiCredentials;
|
|
34
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Slack signed the user in but the browser has no `d` session cookie, which the
|
|
37
|
+
* API needs alongside the token. Observed after "Reject cookies" in Slack's
|
|
38
|
+
* cookie dialog.
|
|
39
|
+
*/
|
|
40
|
+
export declare class SlackSessionCookieMissingError extends LoginFailedError {
|
|
41
|
+
constructor();
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The Slack client loaded, so the user is signed in, but none of its responses
|
|
45
|
+
* carried a session token.
|
|
46
|
+
*/
|
|
47
|
+
export declare class SlackTokenMissingError extends LoginFailedError {
|
|
48
|
+
constructor();
|
|
49
|
+
}
|
|
35
50
|
declare class SlackServiceSession extends SimpleServiceSession {
|
|
36
|
-
|
|
51
|
+
/** Token seen in a response that did not carry the `d` cookie. */
|
|
52
|
+
private pendingToken;
|
|
53
|
+
/** When the Slack client was first seen fully loaded, or null until then. */
|
|
54
|
+
private clientLoadedAt;
|
|
37
55
|
protected getApiCredentialsFromResponse(response: Response): Promise<ApiCredentials | null>;
|
|
56
|
+
/**
|
|
57
|
+
* Notice a login that is stuck: Slack has signed the user in, yet the two
|
|
58
|
+
* things the credentials are made of have not both turned up.
|
|
59
|
+
*
|
|
60
|
+
* A token whose request did not carry the `d` cookie is paired with the
|
|
61
|
+
* cookie from the browser's jar. If the jar has none either, the login is
|
|
62
|
+
* given up on at once: the cookie is set at sign-in, before Slack serves
|
|
63
|
+
* anything containing a token, so it is not still coming.
|
|
64
|
+
*
|
|
65
|
+
* Without a token, the login is given up on once the client has finished
|
|
66
|
+
* loading and has then had ample time to make its own requests without any
|
|
67
|
+
* of them carrying one.
|
|
68
|
+
*/
|
|
69
|
+
whileWaitingForLogin(page: Page): Promise<void>;
|
|
38
70
|
}
|
|
39
71
|
export declare class Slack extends Service {
|
|
40
72
|
readonly name = "slack";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"slack.d.ts","sourceRoot":"","sources":["../../../src/services/slack.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"slack.d.ts","sourceRoot":"","sources":["../../../src/services/slack.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AACjD,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAChE,OAAO,EAAE,gBAAgB,EAAE,OAAO,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAC;AAGjF;;GAEG;AACH,eAAO,MAAM,yBAAyB;;;;;;;;;;;;EAIpC,CAAC;AAEH,MAAM,MAAM,uBAAuB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,yBAAyB,CAAC,CAAC;AAEhF,qBAAa,mBAAoB,YAAW,cAAc;IACxD,QAAQ,CAAC,UAAU,EAAG,OAAO,CAAU;IACvC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;gBAEb,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM;IAK1C,kBAAkB,CAAC,aAAa,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,SAAS,MAAM,EAAE,CAAC;IAUhF,SAAS,IAAI,OAAO,GAAG,SAAS;IAIhC,MAAM,IAAI,uBAAuB;IAQjC,MAAM,CAAC,QAAQ,CAAC,IAAI,EAAE,uBAAuB,GAAG,mBAAmB;CAGpE;AAwBD;;;;GAIG;AACH,qBAAa,8BAA+B,SAAQ,gBAAgB;;CAQnE;AAED;;;GAGG;AACH,qBAAa,sBAAuB,SAAQ,gBAAgB;;CAK3D;AA2BD,cAAM,mBAAoB,SAAQ,oBAAoB;IACpD,kEAAkE;IAClE,OAAO,CAAC,YAAY,CAAuB;IAE3C,6EAA6E;IAC7E,OAAO,CAAC,cAAc,CAAuB;cAE7B,6BAA6B,CAC3C,QAAQ,EAAE,QAAQ,GACjB,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC;IA2BjC;;;;;;;;;;;;OAYG;IACY,oBAAoB,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;CAwB/D;AAED,qBAAa,KAAM,SAAQ,OAAO;IAChC,QAAQ,CAAC,IAAI,WAAW;IACxB,QAAQ,CAAC,WAAW,WAAW;IAC/B,QAAQ,CAAC,WAAW,kEAAmE;IACvF,QAAQ,CAAC,QAAQ,8BAA8B;IAC/C,QAAQ,CAAC,IAAI,SAEyD;IAEtE,QAAQ,CAAC,4BAA4B,+CAAgD;IAErF,qBAAqB,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM;IAIzC,UAAU,CAAC,aAAa,EAAE,MAAM,GAAG,mBAAmB;cAM5C,8BAA8B,CAC/C,eAAe,EAAE,MAAM,EACvB,YAAY,EAAE,MAAM,GACnB,OAAO;IAKD,UAAU,CAAC,cAAc,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;CAuB5E;AAED,eAAO,MAAM,KAAK,OAAc,CAAC"}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Slack service implementation.
|
|
3
3
|
*/
|
|
4
4
|
import { z } from 'zod';
|
|
5
|
-
import { Service, SimpleServiceSession } from './core/base.js';
|
|
5
|
+
import { LoginFailedError, Service, SimpleServiceSession } from './core/base.js';
|
|
6
6
|
import { fetchAccountFromEndpoint, tryParseJson } from '../apiCredentials/account.js';
|
|
7
7
|
/**
|
|
8
8
|
* Slack-specific credentials (token + d cookie).
|
|
@@ -43,38 +43,129 @@ export class SlackApiCredentials {
|
|
|
43
43
|
return new SlackApiCredentials(data.token, data.dCookie);
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Any host under slack.com. Enterprise Grid workspaces live two labels deep
|
|
48
|
+
* (`<workspace>.enterprise.slack.com`), so a single optional label is not enough.
|
|
49
|
+
*/
|
|
50
|
+
const SLACK_URL_PATTERN = /^https:\/\/([a-z0-9-]+\.)*slack\.com\//;
|
|
51
|
+
/** Where the browser ends up once Slack considers the user signed in. */
|
|
52
|
+
const SLACK_CLIENT_URL_PATTERN = /^https:\/\/app\.slack\.com\/client(\/|$)/;
|
|
53
|
+
/** The session token, as embedded in the client's HTML or in raw JSON. */
|
|
54
|
+
const API_TOKEN_PATTERN = /"api_token":"(xoxc-[a-zA-Z0-9-]+)"/;
|
|
55
|
+
const D_COOKIE_HEADER_PATTERN = /\bd=([^;]+)/;
|
|
56
|
+
/**
|
|
57
|
+
* How long after the Slack client has finished loading a token is still waited
|
|
58
|
+
* for. The token may arrive in the client's HTML or in a request the client
|
|
59
|
+
* makes afterwards, so this is deliberately generous: it only delays the
|
|
60
|
+
* failure message, never a login that is going to succeed.
|
|
61
|
+
*/
|
|
62
|
+
const TOKEN_SETTLE_PERIOD_MS = 5_000;
|
|
63
|
+
/**
|
|
64
|
+
* Slack signed the user in but the browser has no `d` session cookie, which the
|
|
65
|
+
* API needs alongside the token. Observed after "Reject cookies" in Slack's
|
|
66
|
+
* cookie dialog.
|
|
67
|
+
*/
|
|
68
|
+
export class SlackSessionCookieMissingError extends LoginFailedError {
|
|
69
|
+
constructor() {
|
|
70
|
+
super('Login failed: The browser holds no "d" session cookie. ' +
|
|
71
|
+
"Maybe you rejected cookies in Slack's cookie dialog?");
|
|
72
|
+
this.name = 'SlackSessionCookieMissingError';
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The Slack client loaded, so the user is signed in, but none of its responses
|
|
77
|
+
* carried a session token.
|
|
78
|
+
*/
|
|
79
|
+
export class SlackTokenMissingError extends LoginFailedError {
|
|
80
|
+
constructor() {
|
|
81
|
+
super('Login failed: No session token was found.');
|
|
82
|
+
this.name = 'SlackTokenMissingError';
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async function readDCookieFromJar(page) {
|
|
86
|
+
const cookies = await page.context().cookies();
|
|
87
|
+
const dCookie = cookies.find((cookie) => cookie.name === 'd' && /(^|\.)slack\.com$/.test(cookie.domain));
|
|
88
|
+
return dCookie?.value ?? null;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Whether the browser sits on a fully loaded Slack client. Loading counts
|
|
92
|
+
* subresources too, so on a slow connection this simply takes longer to become
|
|
93
|
+
* true. A page mid-navigation cannot be asked and counts as not loaded.
|
|
94
|
+
*/
|
|
95
|
+
async function isSlackClientLoaded(page) {
|
|
96
|
+
if (!SLACK_CLIENT_URL_PATTERN.test(page.url())) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
const readyState = await page.evaluate('document.readyState');
|
|
101
|
+
return readyState === 'complete';
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
46
107
|
class SlackServiceSession extends SimpleServiceSession {
|
|
47
|
-
|
|
108
|
+
/** Token seen in a response that did not carry the `d` cookie. */
|
|
109
|
+
pendingToken = null;
|
|
110
|
+
/** When the Slack client was first seen fully loaded, or null until then. */
|
|
111
|
+
clientLoadedAt = null;
|
|
48
112
|
async getApiCredentialsFromResponse(response) {
|
|
49
113
|
const request = response.request();
|
|
50
|
-
|
|
51
|
-
// Check if the domain is under slack.com
|
|
52
|
-
if (!/^https:\/\/([a-z0-9-]+\.)?slack\.com\//.test(url)) {
|
|
114
|
+
if (!SLACK_URL_PATTERN.test(request.url())) {
|
|
53
115
|
return null;
|
|
54
116
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
117
|
+
let responseBody;
|
|
118
|
+
try {
|
|
119
|
+
responseBody = await response.text();
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// The body is no longer retrievable (a redirect, or a page that has
|
|
123
|
+
// navigated on); other responses get their chance.
|
|
58
124
|
return null;
|
|
59
125
|
}
|
|
60
|
-
const
|
|
61
|
-
if (
|
|
126
|
+
const token = API_TOKEN_PATTERN.exec(responseBody)?.[1];
|
|
127
|
+
if (token === undefined) {
|
|
62
128
|
return null;
|
|
63
129
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
130
|
+
this.pendingToken = token;
|
|
131
|
+
const cookieHeader = (await request.allHeaders()).cookie;
|
|
132
|
+
const dCookie = cookieHeader === undefined ? undefined : D_COOKIE_HEADER_PATTERN.exec(cookieHeader)?.[1];
|
|
133
|
+
return dCookie === undefined ? null : new SlackApiCredentials(token, dCookie);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Notice a login that is stuck: Slack has signed the user in, yet the two
|
|
137
|
+
* things the credentials are made of have not both turned up.
|
|
138
|
+
*
|
|
139
|
+
* A token whose request did not carry the `d` cookie is paired with the
|
|
140
|
+
* cookie from the browser's jar. If the jar has none either, the login is
|
|
141
|
+
* given up on at once: the cookie is set at sign-in, before Slack serves
|
|
142
|
+
* anything containing a token, so it is not still coming.
|
|
143
|
+
*
|
|
144
|
+
* Without a token, the login is given up on once the client has finished
|
|
145
|
+
* loading and has then had ample time to make its own requests without any
|
|
146
|
+
* of them carrying one.
|
|
147
|
+
*/
|
|
148
|
+
async whileWaitingForLogin(page) {
|
|
149
|
+
if (this.apiCredentials !== null) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (this.pendingToken !== null) {
|
|
153
|
+
const dCookie = await readDCookieFromJar(page);
|
|
154
|
+
if (dCookie === null) {
|
|
155
|
+
throw new SlackSessionCookieMissingError();
|
|
72
156
|
}
|
|
157
|
+
this.apiCredentials = new SlackApiCredentials(this.pendingToken, dCookie);
|
|
158
|
+
return;
|
|
73
159
|
}
|
|
74
|
-
|
|
75
|
-
|
|
160
|
+
if (this.clientLoadedAt === null) {
|
|
161
|
+
if (await isSlackClientLoaded(page)) {
|
|
162
|
+
this.clientLoadedAt = Date.now();
|
|
163
|
+
}
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (Date.now() - this.clientLoadedAt >= TOKEN_SETTLE_PERIOD_MS) {
|
|
167
|
+
throw new SlackTokenMissingError();
|
|
76
168
|
}
|
|
77
|
-
return null;
|
|
78
169
|
}
|
|
79
170
|
}
|
|
80
171
|
export class Slack extends Service {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"slack.js","sourceRoot":"","sources":["../../../src/services/slack.ts"],"names":[],"mappings":"AAAA;;GAEG;AAGH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,EAAE,OAAO,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"slack.js","sourceRoot":"","sources":["../../../src/services/slack.ts"],"names":[],"mappings":"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"}
|
package/dist/src/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const VERSION = "3.
|
|
1
|
+
export declare const VERSION = "3.11.1";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/dist/src/version.js
CHANGED
|
@@ -83,6 +83,7 @@ describe('buildCurlArguments', () => {
|
|
|
83
83
|
['X-Latchkey-Gateway-Password', 'sekret'],
|
|
84
84
|
['X-Latchkey-Gateway-Permissions-Override', 'jwt-payload'],
|
|
85
85
|
['X-Latchkey-Gateway-Account', 'jou'],
|
|
86
|
+
['X-Latchkey-Gateway-No-Credentials', '1'],
|
|
86
87
|
]);
|
|
87
88
|
const args = buildCurlArguments('GET', headers, 'https://api.example.com/test', false);
|
|
88
89
|
expect(args).toContain('Content-Type: application/json');
|
|
@@ -90,6 +91,7 @@ describe('buildCurlArguments', () => {
|
|
|
90
91
|
expect(joined).not.toContain('x-latchkey-gateway-password');
|
|
91
92
|
expect(joined).not.toContain('x-latchkey-gateway-permissions-override');
|
|
92
93
|
expect(joined).not.toContain('x-latchkey-gateway-account');
|
|
94
|
+
expect(joined).not.toContain('x-latchkey-gateway-no-credentials');
|
|
93
95
|
expect(joined).not.toContain('sekret');
|
|
94
96
|
expect(joined).not.toContain('jwt-payload');
|
|
95
97
|
});
|
|
@@ -760,6 +762,99 @@ describe('gateway server', () => {
|
|
|
760
762
|
expect(body.error).toContain('missing-permissions.json');
|
|
761
763
|
});
|
|
762
764
|
});
|
|
765
|
+
describe('no-credentials header', () => {
|
|
766
|
+
const noCredentialsHeaders = { 'X-Latchkey-Gateway-No-Credentials': '1' };
|
|
767
|
+
const DEFAULT_CREDENTIALS = {
|
|
768
|
+
slack: { objectType: 'rawCurl', curlArguments: ['-H', 'Authorization: Bearer test-token'] },
|
|
769
|
+
};
|
|
770
|
+
// The header is only honored on a gateway that allows forwarding requests
|
|
771
|
+
// without credential injection.
|
|
772
|
+
function createPassthroughGateway(optionOverrides = {}) {
|
|
773
|
+
return createTestGateway(DEFAULT_CREDENTIALS, {}, optionOverrides, {
|
|
774
|
+
passthroughUnknown: true,
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
function headerArgumentsOf(args) {
|
|
778
|
+
const headerArguments = [];
|
|
779
|
+
for (let index = 0; index < args.length; index++) {
|
|
780
|
+
if (args[index] === '-H' && index + 1 < args.length) {
|
|
781
|
+
headerArguments.push(args[index + 1]);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
return headerArguments;
|
|
785
|
+
}
|
|
786
|
+
it('is refused unless the gateway allows uninjected requests', async () => {
|
|
787
|
+
gateway = await createTestGateway();
|
|
788
|
+
const response = await fetch('/gateway/https://slack.com/api/auth.test', {
|
|
789
|
+
headers: { ...noCredentialsHeaders, Authorization: 'Bearer already-injected' },
|
|
790
|
+
});
|
|
791
|
+
expect(response.status).toBe(403);
|
|
792
|
+
expect(await response.text()).toContain('LATCHKEY_PASSTHROUGH_UNKNOWN');
|
|
793
|
+
expect(capturedCurlArgs).toEqual([]);
|
|
794
|
+
expect(capturedPermissionCheckBody).toBeUndefined();
|
|
795
|
+
});
|
|
796
|
+
it('forwards the request as received, without injecting stored credentials', async () => {
|
|
797
|
+
gateway = await createPassthroughGateway();
|
|
798
|
+
const response = await fetch('/gateway/https://slack.com/api/auth.test', {
|
|
799
|
+
headers: { ...noCredentialsHeaders, Authorization: 'Bearer already-injected' },
|
|
800
|
+
});
|
|
801
|
+
expect(response.status).toBe(200);
|
|
802
|
+
const headerArguments = headerArgumentsOf(capturedCurlArgs);
|
|
803
|
+
expect(headerArguments).toContain('Authorization: Bearer already-injected');
|
|
804
|
+
expect(headerArguments).not.toContain('Authorization: Bearer test-token');
|
|
805
|
+
expect(capturedCurlArgs).toContain('https://slack.com/api/auth.test');
|
|
806
|
+
});
|
|
807
|
+
it('never forwards the header itself upstream', async () => {
|
|
808
|
+
gateway = await createPassthroughGateway();
|
|
809
|
+
await fetch('/gateway/https://slack.com/api/auth.test', { headers: noCredentialsHeaders });
|
|
810
|
+
expect(capturedCurlArgs.join('\n').toLowerCase()).not.toContain('x-latchkey-gateway-no-credentials');
|
|
811
|
+
});
|
|
812
|
+
it('still enforces the permission check', async () => {
|
|
813
|
+
mockPermissionResult = false;
|
|
814
|
+
gateway = await createPassthroughGateway();
|
|
815
|
+
const response = await fetch('/gateway/https://slack.com/api/auth.test', {
|
|
816
|
+
headers: noCredentialsHeaders,
|
|
817
|
+
});
|
|
818
|
+
expect(response.status).toBe(403);
|
|
819
|
+
expect(capturedCurlArgs).toEqual([]);
|
|
820
|
+
});
|
|
821
|
+
it('checks permissions against the real request body', async () => {
|
|
822
|
+
gateway = await createPassthroughGateway();
|
|
823
|
+
const requestBody = JSON.stringify({ channel: 'general', text: 'hello' });
|
|
824
|
+
const response = await fetch('/gateway/https://slack.com/api/chat.postMessage', {
|
|
825
|
+
method: 'POST',
|
|
826
|
+
headers: { ...noCredentialsHeaders, 'Content-Type': 'application/json' },
|
|
827
|
+
body: requestBody,
|
|
828
|
+
});
|
|
829
|
+
expect(response.status).toBe(200);
|
|
830
|
+
expect(capturedPermissionCheckBody).toBe(requestBody);
|
|
831
|
+
});
|
|
832
|
+
it('does not require the target to match a known service', async () => {
|
|
833
|
+
gateway = await createPassthroughGateway();
|
|
834
|
+
const response = await fetch('/gateway/https://unknown-api.example.com/test', {
|
|
835
|
+
headers: noCredentialsHeaders,
|
|
836
|
+
});
|
|
837
|
+
expect(response.status).toBe(200);
|
|
838
|
+
expect(capturedCurlArgs).toContain('https://unknown-api.example.com/test');
|
|
839
|
+
});
|
|
840
|
+
it('still requires the gateway password', async () => {
|
|
841
|
+
gateway = await createPassthroughGateway({ password: 'sekret' });
|
|
842
|
+
const unauthenticated = await fetch('/gateway/https://slack.com/api/auth.test', {
|
|
843
|
+
headers: noCredentialsHeaders,
|
|
844
|
+
});
|
|
845
|
+
expect(unauthenticated.status).toBe(401);
|
|
846
|
+
const authenticated = await fetch('/gateway/https://slack.com/api/auth.test', {
|
|
847
|
+
headers: { ...noCredentialsHeaders, 'X-Latchkey-Gateway-Password': 'sekret' },
|
|
848
|
+
});
|
|
849
|
+
expect(authenticated.status).toBe(200);
|
|
850
|
+
});
|
|
851
|
+
it('is ignored when absent, so ordinary requests still get credentials', async () => {
|
|
852
|
+
gateway = await createPassthroughGateway();
|
|
853
|
+
const response = await fetch('/gateway/https://slack.com/api/auth.test');
|
|
854
|
+
expect(response.status).toBe(200);
|
|
855
|
+
expect(capturedCurlArgs).toContain('Authorization: Bearer test-token');
|
|
856
|
+
});
|
|
857
|
+
});
|
|
763
858
|
describe('services registered while the gateway runs', () => {
|
|
764
859
|
const REGISTERED_BASE_URL = 'https://selfhosted.example.com/api/';
|
|
765
860
|
const CREDENTIALS = {
|