latchkey 3.9.0 → 3.10.0

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.
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Tests for the generic token-capturing browser login used by registered
3
+ * services.
4
+ *
5
+ * Everything goes through the public path a real login takes — a flow, the
6
+ * service it is registered on, and the session that service hands out — with
7
+ * responses fed in by hand instead of by a browser.
8
+ */
9
+ import { describe, it, expect } from 'vitest';
10
+ import { TokenCaptureLoginFlow } from '../src/services/core/loginFlows/tokenCapture.js';
11
+ import { LOGIN_FLOWS, resolveLoginFlow } from '../src/services/core/loginFlows/registry.js';
12
+ import { LoginFlowParamsInvalidError } from '../src/services/core/loginFlows/base.js';
13
+ import { RegisteredService } from '../src/services/core/registered.js';
14
+ import { SimpleServiceSession } from '../src/services/index.js';
15
+ const LOGIN_URL = 'https://app.example.com/auth/login';
16
+ const TOKEN_URL = 'https://app.example.com/api/auth/session';
17
+ /** A response carrying the JSON body under test. */
18
+ function responseWith(body, url) {
19
+ return {
20
+ url: () => url,
21
+ text: () => Promise.resolve(body),
22
+ headersArray: () => Promise.resolve([{ name: 'Content-Type', value: 'application/json' }]),
23
+ };
24
+ }
25
+ async function headerFrom(credentials) {
26
+ if (credentials === null) {
27
+ return null;
28
+ }
29
+ const curlArguments = await credentials.injectIntoCurlCall([]);
30
+ return curlArguments[1] ?? null;
31
+ }
32
+ /**
33
+ * A login in progress for a service registered with these parameters: responses
34
+ * go in, and out comes the header stored so far, or null.
35
+ */
36
+ function startLogin(params) {
37
+ const service = new RegisteredService('my-service', 'https://app.example.com/backend-api/', {
38
+ loginUrl: LOGIN_URL,
39
+ loginFlow: new TokenCaptureLoginFlow({
40
+ tokenUrl: params.tokenUrl ?? TOKEN_URL,
41
+ tokenField: params.tokenField ?? 'accessToken',
42
+ ...(params.header === undefined ? {} : { header: params.header }),
43
+ }),
44
+ });
45
+ const session = service.getSession('latchkey');
46
+ if (!(session instanceof SimpleServiceSession)) {
47
+ throw new TypeError('the token-capture flow should hand out a SimpleServiceSession');
48
+ }
49
+ return async (body, responseUrl = TOKEN_URL) => {
50
+ await session.onResponse(responseWith(body, responseUrl));
51
+ return headerFrom(session.capturedCredentials);
52
+ };
53
+ }
54
+ describe('token capture', () => {
55
+ it('captures a token from the named endpoint', async () => {
56
+ const respond = startLogin({});
57
+ expect(await respond('{"accessToken": "tok-123", "user": {"email": "a@example.com"}}')).toBe('Authorization: Bearer tok-123');
58
+ });
59
+ // The signal is the token, not the request: the same endpoint answers a
60
+ // signed-out visitor, and treating that as a finished login would store
61
+ // nothing and call it success.
62
+ it('is not complete while the endpoint answers without a token', async () => {
63
+ const respond = startLogin({});
64
+ expect(await respond('{}')).toBeNull();
65
+ expect(await respond('{"accessToken": null}')).toBeNull();
66
+ expect(await respond('{"accessToken": ""}')).toBeNull();
67
+ expect(await respond('{"accessToken": "tok-123"}')).toBe('Authorization: Bearer tok-123');
68
+ });
69
+ it('ignores responses from other endpoints', async () => {
70
+ const respond = startLogin({});
71
+ expect(await respond('{"accessToken": "not-this-one"}', 'https://app.example.com/api/profile')).toBeNull();
72
+ expect(await respond('{"accessToken": "nor-this"}', 'https://identity-provider.example.net/session')).toBeNull();
73
+ });
74
+ // A mint endpoint is commonly called with a parameter the registrant cannot
75
+ // predict — NextAuth appends one — so matching the URL verbatim would mean
76
+ // the flow silently never fires.
77
+ it('matches the endpoint regardless of query string', async () => {
78
+ const respond = startLogin({});
79
+ expect(await respond('{"accessToken": "tok"}', `${TOKEN_URL}?_rsc=1a2b3`)).toBe('Authorization: Bearer tok');
80
+ });
81
+ it('reads a nested field', async () => {
82
+ const respond = startLogin({ tokenField: 'data.session.token' });
83
+ expect(await respond('{"data": {"session": {"token": "deep"}}}')).toBe('Authorization: Bearer deep');
84
+ });
85
+ it('does not mistake a non-object on the path for a token', async () => {
86
+ const respond = startLogin({ tokenField: 'data.token' });
87
+ expect(await respond('{"data": null}')).toBeNull();
88
+ expect(await respond('{"data": ["token"]}')).toBeNull();
89
+ expect(await respond('{"data": {"token": 42}}')).toBeNull();
90
+ });
91
+ // Waiting rather than failing: the response that carries the token may still
92
+ // be coming, and a login that aborted on the first unparseable body would be
93
+ // at the mercy of anything else the page fetches from that path.
94
+ it('keeps waiting through a body that is not JSON', async () => {
95
+ const respond = startLogin({});
96
+ expect(await respond('<!doctype html><title>Just a moment...</title>')).toBeNull();
97
+ expect(await respond('{"accessToken": "tok"}')).toBe('Authorization: Bearer tok');
98
+ });
99
+ it('stores the token in a custom header when one is registered', async () => {
100
+ const respond = startLogin({ header: 'X-Auth-Token: {token}' });
101
+ expect(await respond('{"accessToken": "tok"}')).toBe('X-Auth-Token: tok');
102
+ });
103
+ });
104
+ describe('token capture parameters', () => {
105
+ it('is registered under its flow name', () => {
106
+ expect(LOGIN_FLOWS.map((flow) => flow.flowName)).toContain('token-capture');
107
+ expect(resolveLoginFlow('token-capture', { tokenUrl: TOKEN_URL, tokenField: 'accessToken' })).toBeInstanceOf(TokenCaptureLoginFlow);
108
+ });
109
+ it('requires a URL and a field', () => {
110
+ expect(() => new TokenCaptureLoginFlow({})).toThrow(LoginFlowParamsInvalidError);
111
+ expect(() => new TokenCaptureLoginFlow({ tokenUrl: TOKEN_URL })).toThrow(LoginFlowParamsInvalidError);
112
+ expect(() => new TokenCaptureLoginFlow({ tokenUrl: 'not-a-url', tokenField: 'a' })).toThrow(LoginFlowParamsInvalidError);
113
+ });
114
+ // Caught at registration rather than as a puzzling 401 after a
115
+ // successful-looking login.
116
+ it('rejects a header that could not carry the token', () => {
117
+ const base = { tokenUrl: TOKEN_URL, tokenField: 'accessToken' };
118
+ expect(() => new TokenCaptureLoginFlow({ ...base, header: 'Authorization: Bearer' })).toThrow(LoginFlowParamsInvalidError);
119
+ expect(() => new TokenCaptureLoginFlow({ ...base, header: 'no colon {token}' })).toThrow(LoginFlowParamsInvalidError);
120
+ });
121
+ it('rejects unknown parameters', () => {
122
+ expect(() => new TokenCaptureLoginFlow({
123
+ tokenUrl: TOKEN_URL,
124
+ tokenField: 'accessToken',
125
+ cookieKeys: ['sessionid'],
126
+ })).toThrow(LoginFlowParamsInvalidError);
127
+ });
128
+ it('describes itself for `services info`', () => {
129
+ const flow = new TokenCaptureLoginFlow({ tokenUrl: TOKEN_URL, tokenField: 'accessToken' });
130
+ const description = flow.describe(LOGIN_URL);
131
+ expect(description).toContain(LOGIN_URL);
132
+ expect(description).toContain(TOKEN_URL);
133
+ expect(description).toContain('accessToken');
134
+ });
135
+ });
136
+ //# sourceMappingURL=tokenCapture.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tokenCapture.test.js","sourceRoot":"","sources":["../../tests/tokenCapture.test.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "latchkey",
3
- "version": "3.9.0",
3
+ "version": "3.10.0",
4
4
  "description": "A CLI tool that injects API credentials into curl requests to third-party services",
5
5
  "author": "Imbue <hynek@imbue.com>",
6
6
  "repository": {
@@ -60,7 +60,7 @@
60
60
  "node": ">=20"
61
61
  },
62
62
  "dependencies": {
63
- "@imbue-ai/detent": "^1.15.0",
63
+ "@imbue-ai/detent": "^1.15.1",
64
64
  "@napi-rs/keyring": "^1.2.0",
65
65
  "commander": "^12.0.0",
66
66
  "playwright": "~1.60.0",