@stage-labs/metro 0.1.0-beta.175 → 0.1.0-beta.176
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/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/accounts-api.ts +9 -3
- package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-driver.ts +42 -0
- package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-interactive.ts +2 -55
- package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-outlook.ts +62 -0
- package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-session.ts +15 -11
- package/runtime/node_modules/@metro-labs/outlook/src/auth.ts +14 -6
- package/runtime/node_modules/@metro-labs/outlook/src/browser.ts +52 -0
- package/runtime/node_modules/@metro-labs/outlook/src/config.ts +5 -0
- package/runtime/node_modules/@metro-labs/outlook/src/login.ts +43 -12
- package/runtime/runtime.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stage-labs/metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.176",
|
|
4
4
|
"description": "The metro command line. Sign in once per machine, then hand your MCP connector list to Claude Code without the credentials touching disk, argv or shell history.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.176",
|
|
4
4
|
"description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Bonustrack",
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
isInteractiveStation,
|
|
27
27
|
type InteractiveStation,
|
|
28
28
|
} from '../stations/attach-interactive.js';
|
|
29
|
+
import type { StepInput } from '../stations/attach-driver.js';
|
|
29
30
|
import {
|
|
30
31
|
type AttachOwner,
|
|
31
32
|
type AttachView,
|
|
@@ -41,7 +42,7 @@ export interface AttachSessionApi {
|
|
|
41
42
|
submit: (
|
|
42
43
|
owner: AttachOwner,
|
|
43
44
|
attachId: string,
|
|
44
|
-
input:
|
|
45
|
+
input: StepInput,
|
|
45
46
|
) => Promise<AttachView>;
|
|
46
47
|
cancel: (owner: AttachOwner, attachId: string) => Promise<void>;
|
|
47
48
|
}
|
|
@@ -319,9 +320,14 @@ async function handleStep(
|
|
|
319
320
|
attachId: string,
|
|
320
321
|
): Promise<void> {
|
|
321
322
|
const body = await readJsonBody(req);
|
|
323
|
+
const field = (key: string): unknown => bodyField(body, key);
|
|
322
324
|
const view = await deps.attachSessions.submit(owner, attachId, {
|
|
323
|
-
code:
|
|
324
|
-
password:
|
|
325
|
+
code: field('code'),
|
|
326
|
+
password: field('password'),
|
|
327
|
+
state: field('state'),
|
|
328
|
+
mode: field('mode'),
|
|
329
|
+
error: field('error'),
|
|
330
|
+
errorDescription: field('errorDescription'),
|
|
325
331
|
});
|
|
326
332
|
sendJson(req, res, 200, view);
|
|
327
333
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export type AttachStep = 'code' | 'password' | 'scan' | 'pair' | 'device' | 'browser';
|
|
2
|
+
|
|
3
|
+
export interface AttachPrompt {
|
|
4
|
+
step: AttachStep;
|
|
5
|
+
prompt: string;
|
|
6
|
+
qr?: string;
|
|
7
|
+
pairingCode?: string;
|
|
8
|
+
userCode?: string;
|
|
9
|
+
verificationUri?: string;
|
|
10
|
+
authorizeUrl?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface AttachOutcome {
|
|
14
|
+
config: Record<string, unknown>;
|
|
15
|
+
identity: Record<string, string>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface DriverHooks {
|
|
19
|
+
prompt: (p: AttachPrompt) => void;
|
|
20
|
+
done: (o: AttachOutcome) => void;
|
|
21
|
+
fail: (message: string) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface StepInput {
|
|
25
|
+
code?: unknown;
|
|
26
|
+
password?: unknown;
|
|
27
|
+
state?: unknown;
|
|
28
|
+
mode?: unknown;
|
|
29
|
+
error?: unknown;
|
|
30
|
+
errorDescription?: unknown;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface AttachDriver {
|
|
34
|
+
submit: (input: StepInput) => Promise<void>;
|
|
35
|
+
cancel: () => Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface StartedAttach {
|
|
39
|
+
driver: AttachDriver;
|
|
40
|
+
prompt: AttachPrompt;
|
|
41
|
+
expiresAt?: number;
|
|
42
|
+
}
|
|
@@ -1,42 +1,11 @@
|
|
|
1
1
|
import { StationAttachError } from './attach.js';
|
|
2
|
+
import type { DriverHooks, StartedAttach } from './attach-driver.js';
|
|
3
|
+
import { startOutlook } from './attach-outlook.js';
|
|
2
4
|
|
|
3
5
|
export const INTERACTIVE_STATIONS = ['telegram', 'whatsapp', 'outlook'] as const;
|
|
4
6
|
|
|
5
7
|
export type InteractiveStation = (typeof INTERACTIVE_STATIONS)[number];
|
|
6
8
|
|
|
7
|
-
export type AttachStep = 'code' | 'password' | 'scan' | 'pair' | 'device';
|
|
8
|
-
|
|
9
|
-
export interface AttachPrompt {
|
|
10
|
-
step: AttachStep;
|
|
11
|
-
prompt: string;
|
|
12
|
-
qr?: string;
|
|
13
|
-
pairingCode?: string;
|
|
14
|
-
userCode?: string;
|
|
15
|
-
verificationUri?: string;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export interface AttachOutcome {
|
|
19
|
-
config: Record<string, unknown>;
|
|
20
|
-
identity: Record<string, string>;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export interface DriverHooks {
|
|
24
|
-
prompt: (p: AttachPrompt) => void;
|
|
25
|
-
done: (o: AttachOutcome) => void;
|
|
26
|
-
fail: (message: string) => void;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export interface AttachDriver {
|
|
30
|
-
submit: (input: { code?: unknown; password?: unknown }) => Promise<void>;
|
|
31
|
-
cancel: () => Promise<void>;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export interface StartedAttach {
|
|
35
|
-
driver: AttachDriver;
|
|
36
|
-
prompt: AttachPrompt;
|
|
37
|
-
expiresAt?: number;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
9
|
const CODE_PROMPT =
|
|
41
10
|
'Telegram sent a login code to that number. Enter it here.';
|
|
42
11
|
const PASSWORD_PROMPT =
|
|
@@ -153,28 +122,6 @@ async function startWhatsapp(
|
|
|
153
122
|
};
|
|
154
123
|
}
|
|
155
124
|
|
|
156
|
-
const DEVICE_PROMPT =
|
|
157
|
-
'Open the Microsoft sign-in page, type this code, and sign in with the mailbox this agent should read.';
|
|
158
|
-
|
|
159
|
-
async function startOutlook(hooks: DriverHooks): Promise<StartedAttach> {
|
|
160
|
-
const { OutlookLogin, OutlookLoginError } = await import('@metro-labs/outlook/login');
|
|
161
|
-
const login = new OutlookLogin({ onDone: hooks.done, onFailed: hooks.fail });
|
|
162
|
-
const code = await login.start().catch((err: unknown) =>
|
|
163
|
-
refuse(err instanceof OutlookLoginError ? err : null, 'Microsoft refused to start the sign-in'),
|
|
164
|
-
);
|
|
165
|
-
return {
|
|
166
|
-
prompt: { step: 'device', prompt: DEVICE_PROMPT, userCode: code.userCode, verificationUri: code.verificationUri },
|
|
167
|
-
expiresAt: code.expiresAt,
|
|
168
|
-
driver: {
|
|
169
|
-
cancel: () => login.cancel(),
|
|
170
|
-
submit: () =>
|
|
171
|
-
Promise.reject(
|
|
172
|
-
new StationAttachError('this sign-in finishes on the Microsoft page, there is nothing to submit here', 409),
|
|
173
|
-
),
|
|
174
|
-
},
|
|
175
|
-
};
|
|
176
|
-
}
|
|
177
|
-
|
|
178
125
|
export async function startInteractiveAttach(
|
|
179
126
|
station: InteractiveStation,
|
|
180
127
|
input: Record<string, unknown>,
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { OutlookLogin } from '@metro-labs/outlook/login';
|
|
2
|
+
import { StationAttachError } from './attach.js';
|
|
3
|
+
import type { DriverHooks, StartedAttach, StepInput } from './attach-driver.js';
|
|
4
|
+
|
|
5
|
+
const BROWSER_PROMPT =
|
|
6
|
+
'Sign in with Microsoft in a new tab, with the mailbox this agent should read. This page updates on its own once you are done.';
|
|
7
|
+
const DEVICE_PROMPT =
|
|
8
|
+
'Open the Microsoft sign-in page, type this code, and sign in with the mailbox this agent should read.';
|
|
9
|
+
const OTHER_ATTEMPT = 'This sign-in link belongs to another attempt. Start again from the Channels page.';
|
|
10
|
+
const BROWSER_TTL_MS = 15 * 60_000;
|
|
11
|
+
|
|
12
|
+
const text = (v: unknown): string => (typeof v === 'string' ? v.trim() : '');
|
|
13
|
+
|
|
14
|
+
export async function startOutlook(hooks: DriverHooks): Promise<StartedAttach> {
|
|
15
|
+
const { OutlookBrowserLogin, OutlookLogin: DeviceLogin, OutlookLoginError, failureOf } = await import('@metro-labs/outlook/login');
|
|
16
|
+
const said = (err: unknown, fallback: string): string =>
|
|
17
|
+
err instanceof OutlookLoginError && err.message !== '' ? err.message : fallback;
|
|
18
|
+
const browser = (() => {
|
|
19
|
+
try {
|
|
20
|
+
return new OutlookBrowserLogin();
|
|
21
|
+
} catch (err) {
|
|
22
|
+
throw new StationAttachError(said(err, 'Microsoft refused to start the sign-in'), 400);
|
|
23
|
+
}
|
|
24
|
+
})();
|
|
25
|
+
let device: OutlookLogin | null = null;
|
|
26
|
+
|
|
27
|
+
const toDevice = async (): Promise<void> => {
|
|
28
|
+
if (device !== null) return;
|
|
29
|
+
const login = new DeviceLogin({ onDone: hooks.done, onFailed: hooks.fail });
|
|
30
|
+
const code = await login.start().catch((err: unknown) => {
|
|
31
|
+
throw new StationAttachError(said(err, 'Microsoft refused to start the sign-in'), 400);
|
|
32
|
+
});
|
|
33
|
+
device = login;
|
|
34
|
+
hooks.prompt({ step: 'device', prompt: DEVICE_PROMPT, userCode: code.userCode, verificationUri: code.verificationUri });
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const finish = async (input: StepInput): Promise<void> => {
|
|
38
|
+
const state = text(input.state);
|
|
39
|
+
if (state !== browser.state) throw new StationAttachError(OTHER_ATTEMPT, 400);
|
|
40
|
+
const error = text(input.error);
|
|
41
|
+
if (error !== '') {
|
|
42
|
+
hooks.fail(failureOf({ error, error_description: text(input.errorDescription) }));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const code = text(input.code);
|
|
46
|
+
if (code === '') throw new StationAttachError('Microsoft did not send a sign-in code back', 400);
|
|
47
|
+
try {
|
|
48
|
+
hooks.done(await browser.finish(code, state));
|
|
49
|
+
} catch (err) {
|
|
50
|
+
hooks.fail(said(err, 'Metro could not finish the Microsoft sign-in.'));
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
prompt: { step: 'browser', prompt: BROWSER_PROMPT, authorizeUrl: browser.authorizeUrl },
|
|
56
|
+
expiresAt: Date.now() + BROWSER_TTL_MS,
|
|
57
|
+
driver: {
|
|
58
|
+
cancel: () => device?.cancel() ?? Promise.resolve(),
|
|
59
|
+
submit: (input) => (input.mode === 'device' ? toDevice() : finish(input)),
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -3,16 +3,16 @@ import { ApiError } from '@metro-labs/http/api-error';
|
|
|
3
3
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
4
4
|
import type { StationName } from '@metro-labs/core/station-names';
|
|
5
5
|
import { ensureStationDeps } from './runtime-deps.js';
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
} from './attach-
|
|
6
|
+
import { startInteractiveAttach, type InteractiveStation } from './attach-interactive.js';
|
|
7
|
+
import type {
|
|
8
|
+
AttachDriver,
|
|
9
|
+
AttachOutcome,
|
|
10
|
+
AttachPrompt,
|
|
11
|
+
AttachStep,
|
|
12
|
+
DriverHooks,
|
|
13
|
+
StartedAttach,
|
|
14
|
+
StepInput,
|
|
15
|
+
} from './attach-driver.js';
|
|
16
16
|
|
|
17
17
|
export type StartAttach = (
|
|
18
18
|
station: InteractiveStation,
|
|
@@ -42,6 +42,7 @@ export interface AttachView {
|
|
|
42
42
|
pairingCode: string | null;
|
|
43
43
|
userCode: string | null;
|
|
44
44
|
verificationUri: string | null;
|
|
45
|
+
authorizeUrl: string | null;
|
|
45
46
|
accountId: string | null;
|
|
46
47
|
identity: Record<string, string>;
|
|
47
48
|
activated: boolean;
|
|
@@ -86,6 +87,7 @@ function blankView(attachId: string, station: string): AttachView {
|
|
|
86
87
|
pairingCode: null,
|
|
87
88
|
userCode: null,
|
|
88
89
|
verificationUri: null,
|
|
90
|
+
authorizeUrl: null,
|
|
89
91
|
accountId: null,
|
|
90
92
|
identity: {},
|
|
91
93
|
activated: false,
|
|
@@ -138,6 +140,7 @@ export class AttachSessions {
|
|
|
138
140
|
pairingCode: null,
|
|
139
141
|
userCode: null,
|
|
140
142
|
verificationUri: null,
|
|
143
|
+
authorizeUrl: null,
|
|
141
144
|
step: null,
|
|
142
145
|
expiresAt: Date.now() + SETTLED_TTL_MS,
|
|
143
146
|
});
|
|
@@ -187,6 +190,7 @@ export class AttachSessions {
|
|
|
187
190
|
session.view.pairingCode = p.pairingCode ?? null;
|
|
188
191
|
session.view.userCode = p.userCode ?? null;
|
|
189
192
|
session.view.verificationUri = p.verificationUri ?? null;
|
|
193
|
+
session.view.authorizeUrl = p.authorizeUrl ?? null;
|
|
190
194
|
},
|
|
191
195
|
done: (o: AttachOutcome): void => {
|
|
192
196
|
this.finish(attachId, station, o).catch((err: unknown) => {
|
|
@@ -247,7 +251,7 @@ export class AttachSessions {
|
|
|
247
251
|
async submit(
|
|
248
252
|
owner: AttachOwner,
|
|
249
253
|
attachId: string,
|
|
250
|
-
input:
|
|
254
|
+
input: StepInput,
|
|
251
255
|
): Promise<AttachView> {
|
|
252
256
|
const session = this.own(owner, attachId);
|
|
253
257
|
const driver = session.driver;
|
|
@@ -29,6 +29,7 @@ type Body = Record<string, unknown>;
|
|
|
29
29
|
|
|
30
30
|
const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
|
|
31
31
|
const CONSENT_CODES = new Set([65001, 90094, 90095]);
|
|
32
|
+
const POLICY_CODES = new Set([530035, 53003]);
|
|
32
33
|
|
|
33
34
|
const text = (v: unknown): string => (typeof v === 'string' ? v : '');
|
|
34
35
|
const seconds = (v: unknown, fallback: number): number =>
|
|
@@ -39,7 +40,7 @@ export function requireClientId(id = clientId()): string {
|
|
|
39
40
|
return id;
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
async function postForm(path: string, fields: Record<string, string>, fetchImpl: FetchLike): Promise<{ status: number; body: Body }> {
|
|
43
|
+
export async function postForm(path: string, fields: Record<string, string>, fetchImpl: FetchLike): Promise<{ status: number; body: Body }> {
|
|
43
44
|
let res: Response;
|
|
44
45
|
try {
|
|
45
46
|
res = await fetchImpl(`${loginBase()}/oauth2/v2.0/${path}`, {
|
|
@@ -63,13 +64,20 @@ function codesOf(body: Body): number[] {
|
|
|
63
64
|
const needsConsent = (error: string, codes: number[]): boolean =>
|
|
64
65
|
error === 'consent_required' || codes.some((c) => CONSENT_CODES.has(c));
|
|
65
66
|
|
|
66
|
-
|
|
67
|
-
const error = text(body.error);
|
|
68
|
-
const codes = codesOf(body);
|
|
67
|
+
function knownFailure(error: string, codes: number[], id: string): string | null {
|
|
69
68
|
if (needsConsent(error, codes))
|
|
70
|
-
return `An administrator of this Microsoft 365 organization must approve Metro once before it can read this mailbox. They can do it at https://login.microsoftonline.com/
|
|
69
|
+
return `An administrator of this Microsoft 365 organization must approve Metro once before it can read this mailbox. They can do it at https://login.microsoftonline.com/organizations/adminconsent?client_id=${id} and then you can start again.`;
|
|
70
|
+
if (codes.some((c) => POLICY_CODES.has(c)))
|
|
71
|
+
return 'Your company blocks this kind of sign-in. Ask your Microsoft 365 administrator, or use the Microsoft sign-in page instead of a code.';
|
|
71
72
|
if (error === 'expired_token' || codes.includes(70020)) return 'The sign-in code expired before it was used. Start again.';
|
|
72
73
|
if (error === 'access_denied' || codes.includes(65004)) return 'The sign-in was declined, so nothing was connected.';
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function failureOf(body: Body, id = clientId()): string {
|
|
78
|
+
const error = text(body.error);
|
|
79
|
+
const known = knownFailure(error, codesOf(body), id);
|
|
80
|
+
if (known !== null) return known;
|
|
73
81
|
const detail = text(body.error_description).split(/\r?\n/)[0] ?? '';
|
|
74
82
|
return detail === '' ? `Microsoft refused the sign-in (${error === '' ? 'no reason given' : error}).` : `Microsoft refused the sign-in: ${detail}`;
|
|
75
83
|
}
|
|
@@ -93,7 +101,7 @@ export function tenantOf(...jwts: string[]): string | null {
|
|
|
93
101
|
return null;
|
|
94
102
|
}
|
|
95
103
|
|
|
96
|
-
function tokensOf(body: Body, now: number, previous?: string): Tokens {
|
|
104
|
+
export function tokensOf(body: Body, now: number, previous?: string): Tokens {
|
|
97
105
|
const accessToken = text(body.access_token);
|
|
98
106
|
const refreshToken = text(body.refresh_token) || (previous ?? '');
|
|
99
107
|
if (accessToken === '' || refreshToken === '') throw new OutlookAuthError('Microsoft answered without the tokens Metro needs.');
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { failureOf, OutlookAuthError, postForm, requireClientId, tenantOf, tokensOf, type FetchLike, type Tokens } from './auth.js';
|
|
3
|
+
import { loginBase, redirectUri, SCOPES } from './config.js';
|
|
4
|
+
|
|
5
|
+
export interface Pkce {
|
|
6
|
+
verifier: string;
|
|
7
|
+
challenge: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function pkcePair(verifier = randomBytes(32).toString('base64url')): Pkce {
|
|
11
|
+
return { verifier, challenge: createHash('sha256').update(verifier).digest('base64url') };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const newState = (): string => randomBytes(24).toString('base64url');
|
|
15
|
+
|
|
16
|
+
export function authorizeUrl(challenge: string, state: string): string {
|
|
17
|
+
const query = new URLSearchParams({
|
|
18
|
+
client_id: requireClientId(),
|
|
19
|
+
response_type: 'code',
|
|
20
|
+
redirect_uri: redirectUri(),
|
|
21
|
+
response_mode: 'query',
|
|
22
|
+
scope: SCOPES,
|
|
23
|
+
state,
|
|
24
|
+
code_challenge: challenge,
|
|
25
|
+
code_challenge_method: 'S256',
|
|
26
|
+
prompt: 'select_account',
|
|
27
|
+
});
|
|
28
|
+
return `${loginBase()}/oauth2/v2.0/authorize?${query.toString()}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface Redeemed {
|
|
32
|
+
tokens: Tokens;
|
|
33
|
+
tenantId: string | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function redeemCode(code: string, verifier: string, fetchImpl: FetchLike, now = Date.now()): Promise<Redeemed> {
|
|
37
|
+
const { status, body } = await postForm(
|
|
38
|
+
'token',
|
|
39
|
+
{
|
|
40
|
+
grant_type: 'authorization_code',
|
|
41
|
+
client_id: requireClientId(),
|
|
42
|
+
code,
|
|
43
|
+
code_verifier: verifier,
|
|
44
|
+
redirect_uri: redirectUri(),
|
|
45
|
+
scope: SCOPES,
|
|
46
|
+
},
|
|
47
|
+
fetchImpl,
|
|
48
|
+
);
|
|
49
|
+
if (status !== 200) throw new OutlookAuthError(failureOf(body));
|
|
50
|
+
const tokens = tokensOf(body, now);
|
|
51
|
+
return { tokens, tenantId: tenantOf(typeof body.id_token === 'string' ? body.id_token : '', tokens.accessToken) };
|
|
52
|
+
}
|
|
@@ -27,5 +27,10 @@ const base = (raw: string | undefined, fallback: string): string => {
|
|
|
27
27
|
export const loginBase = (): string =>
|
|
28
28
|
base(process.env.METRO_OUTLOOK_LOGIN_URL, 'https://login.microsoftonline.com/common');
|
|
29
29
|
|
|
30
|
+
export const redirectUri = (): string => {
|
|
31
|
+
const value = trimmed(process.env.METRO_OUTLOOK_REDIRECT);
|
|
32
|
+
return value === '' ? 'https://metro.box/' : value;
|
|
33
|
+
};
|
|
34
|
+
|
|
30
35
|
export const graphBase = (): string =>
|
|
31
36
|
base(process.env.METRO_OUTLOOK_GRAPH_URL, 'https://graph.microsoft.com/v1.0');
|
|
@@ -5,11 +5,14 @@ import {
|
|
|
5
5
|
type DeviceCode,
|
|
6
6
|
type FetchLike,
|
|
7
7
|
type Poll,
|
|
8
|
+
type Tokens,
|
|
8
9
|
} from './auth.js';
|
|
9
|
-
import {
|
|
10
|
+
import { authorizeUrl, newState, pkcePair, redeemCode, type Pkce } from './browser.js';
|
|
11
|
+
import { verifyMailbox, type Mailbox } from './me.js';
|
|
10
12
|
|
|
11
13
|
export { OutlookAuthError as OutlookLoginError } from './auth.js';
|
|
12
14
|
export { NOT_SET_UP } from './config.js';
|
|
15
|
+
export { failureOf } from './auth.js';
|
|
13
16
|
|
|
14
17
|
export interface OutlookLoginResult {
|
|
15
18
|
config: Record<string, unknown>;
|
|
@@ -28,6 +31,44 @@ export interface OutlookLoginDeps {
|
|
|
28
31
|
|
|
29
32
|
const SLOW_DOWN_MS = 5_000;
|
|
30
33
|
|
|
34
|
+
function resultOf(tokens: Tokens, tenantId: string | null, mailbox: Mailbox, now: number): OutlookLoginResult {
|
|
35
|
+
return {
|
|
36
|
+
config: {
|
|
37
|
+
accountEmail: mailbox.email,
|
|
38
|
+
refreshToken: tokens.refreshToken,
|
|
39
|
+
accessToken: tokens.accessToken,
|
|
40
|
+
expiresAt: tokens.expiresAt,
|
|
41
|
+
tenantId,
|
|
42
|
+
createdAt: new Date(now).toISOString(),
|
|
43
|
+
},
|
|
44
|
+
identity: { email: mailbox.email, ...(mailbox.name === '' ? {} : { name: mailbox.name }) },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class OutlookBrowserLogin {
|
|
49
|
+
readonly state = newState();
|
|
50
|
+
readonly authorizeUrl: string;
|
|
51
|
+
private readonly pkce: Pkce = pkcePair();
|
|
52
|
+
private used = false;
|
|
53
|
+
private readonly fetchImpl: FetchLike;
|
|
54
|
+
private readonly now: () => number;
|
|
55
|
+
|
|
56
|
+
constructor(deps: OutlookLoginDeps = {}) {
|
|
57
|
+
this.fetchImpl = deps.fetch ?? ((input, init) => fetch(input, init));
|
|
58
|
+
this.now = deps.now ?? Date.now;
|
|
59
|
+
this.authorizeUrl = authorizeUrl(this.pkce.challenge, this.state);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async finish(code: string, state: string): Promise<OutlookLoginResult> {
|
|
63
|
+
if (state !== this.state)
|
|
64
|
+
throw new OutlookAuthError('This sign-in link belongs to another attempt. Start again from the Channels page.');
|
|
65
|
+
if (this.used) throw new OutlookAuthError('This sign-in was already used. Start again from the Channels page.');
|
|
66
|
+
this.used = true;
|
|
67
|
+
const { tokens, tenantId } = await redeemCode(code, this.pkce.verifier, this.fetchImpl, this.now());
|
|
68
|
+
return resultOf(tokens, tenantId, await verifyMailbox(tokens.accessToken, this.fetchImpl), this.now());
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
31
72
|
export class OutlookLogin {
|
|
32
73
|
private code: DeviceCode | null = null;
|
|
33
74
|
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -97,16 +138,6 @@ export class OutlookLogin {
|
|
|
97
138
|
const mailbox = await verifyMailbox(poll.tokens.accessToken, this.fetchImpl);
|
|
98
139
|
if (this.stopped) return;
|
|
99
140
|
this.stopped = true;
|
|
100
|
-
this.events.onDone(
|
|
101
|
-
config: {
|
|
102
|
-
accountEmail: mailbox.email,
|
|
103
|
-
refreshToken: poll.tokens.refreshToken,
|
|
104
|
-
accessToken: poll.tokens.accessToken,
|
|
105
|
-
expiresAt: poll.tokens.expiresAt,
|
|
106
|
-
tenantId: poll.tenantId,
|
|
107
|
-
createdAt: new Date(this.now()).toISOString(),
|
|
108
|
-
},
|
|
109
|
-
identity: { email: mailbox.email, ...(mailbox.name === '' ? {} : { name: mailbox.name }) },
|
|
110
|
-
});
|
|
141
|
+
this.events.onDone(resultOf(poll.tokens, poll.tenantId, mailbox, this.now()));
|
|
111
142
|
}
|
|
112
143
|
}
|
package/runtime/runtime.json
CHANGED