@naswerks/periscope 1.0.0 → 1.0.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 +6 -0
- package/README.md +2 -2
- package/contracts/wire-vectors/bulk_delivered.full.json +21 -0
- package/contracts/wire-vectors/bulk_delivered.minimal.json +21 -0
- package/contracts/wire-vectors/bulk_delivered.without-stat-pair.json +22 -0
- package/contracts/wire-vectors/bulk_failed.declared-reason.json +22 -0
- package/contracts/wire-vectors/bulk_failed.unknown-reason.json +23 -0
- package/contracts/wire-vectors/bulk_request.full.json +21 -0
- package/contracts/wire-vectors/host_configure.set-and-remove.json +28 -0
- package/contracts/wire-vectors/host_configure_result.applied.json +34 -0
- package/contracts/wire-vectors/host_configure_result.refused.json +33 -0
- package/contracts/wire-vectors/link_ack.cursors.json +25 -0
- package/contracts/wire-vectors/link_ack.empty.json +16 -0
- package/contracts/wire-vectors/link_bye.cause.json +16 -0
- package/contracts/wire-vectors/link_hello.empty.json +33 -0
- package/contracts/wire-vectors/link_hello.full.json +44 -0
- package/contracts/wire-vectors/link_ping.nonce.json +16 -0
- package/contracts/wire-vectors/link_ping.unknown-payload-key.json +18 -0
- package/contracts/wire-vectors/link_pong.nonce.json +16 -0
- package/contracts/wire-vectors/link_welcome.empty.json +18 -0
- package/contracts/wire-vectors/link_welcome.full.json +25 -0
- package/contracts/wire-vectors/refused.empty.json +10 -0
- package/contracts/wire-vectors/refused.encode.empty-at.json +18 -0
- package/contracts/wire-vectors/refused.encode.empty-session-id.json +18 -0
- package/contracts/wire-vectors/refused.encode.missing-member.json +22 -0
- package/contracts/wire-vectors/refused.encode.seq-zero.json +18 -0
- package/contracts/wire-vectors/refused.encode.undeclared-configure-refusal.json +34 -0
- package/contracts/wire-vectors/refused.encode.undeclared-wire-refusal.json +27 -0
- package/contracts/wire-vectors/refused.missing-member.json +10 -0
- package/contracts/wire-vectors/refused.not-json.json +10 -0
- package/contracts/wire-vectors/refused.over-limit.json +10 -0
- package/contracts/wire-vectors/refused.seq-zero.json +10 -0
- package/contracts/wire-vectors/refused.unknown-frame.json +10 -0
- package/contracts/wire-vectors/refused.unknown-kind.json +10 -0
- package/contracts/wire-vectors/repository_list.root.json +19 -0
- package/contracts/wire-vectors/repository_list.subdirectory.json +19 -0
- package/contracts/wire-vectors/repository_list_result.entries.json +34 -0
- package/contracts/wire-vectors/repository_list_result.refused.json +24 -0
- package/contracts/wire-vectors/repository_read.head.json +20 -0
- package/contracts/wire-vectors/repository_read_result.refused.json +25 -0
- package/contracts/wire-vectors/repository_read_result.truncated.json +22 -0
- package/contracts/wire-vectors/session_cancel.bare.json +17 -0
- package/contracts/wire-vectors/session_configure.full.json +22 -0
- package/contracts/wire-vectors/session_configure.minimal.json +20 -0
- package/contracts/wire-vectors/session_delta.agent-message.json +27 -0
- package/contracts/wire-vectors/session_list.request.json +18 -0
- package/contracts/wire-vectors/session_list_result.empty.json +21 -0
- package/contracts/wire-vectors/session_list_result.full.json +36 -0
- package/contracts/wire-vectors/session_new.full.json +72 -0
- package/contracts/wire-vectors/session_new.minimal.json +22 -0
- package/contracts/wire-vectors/session_new.request-all-null.json +41 -0
- package/contracts/wire-vectors/session_prompt.empty.json +18 -0
- package/contracts/wire-vectors/session_prompt.multibyte.json +18 -0
- package/contracts/wire-vectors/session_prompt.text.json +18 -0
- package/contracts/wire-vectors/session_prompt.unknown-frame-key.json +20 -0
- package/contracts/wire-vectors/session_prompt.unknown-payload-key.json +20 -0
- package/contracts/wire-vectors/session_update.agent-message.json +26 -0
- package/contracts/wire-vectors/session_update.state-transition-with-activity.json +44 -0
- package/contracts/wire-vectors/session_update.state-transition.json +41 -0
- package/contracts/wire-vectors/session_update.wire-refusal.json +26 -0
- package/contracts/wire-vectors/transcript_failed.declared-reason.json +22 -0
- package/contracts/wire-vectors/transcript_list.from-start.json +19 -0
- package/contracts/wire-vectors/transcript_list.paged.json +19 -0
- package/contracts/wire-vectors/transcript_list_result.entry-without-cwd.json +30 -0
- package/contracts/wire-vectors/transcript_list_result.full.json +29 -0
- package/contracts/wire-vectors/transcript_list_result.last-page.json +29 -0
- package/contracts/wire-vectors/transcript_tail.any-user-entry.json +22 -0
- package/contracts/wire-vectors/transcript_tail.with-needle.json +22 -0
- package/contracts/wire-vectors/transcript_tail_result.absent.json +23 -0
- package/contracts/wire-vectors/transcript_tail_result.found.json +23 -0
- package/contracts/wire-vectors/workspace_list.from-start.json +19 -0
- package/contracts/wire-vectors/workspace_list_result.page.json +48 -0
- package/contracts/wire-vectors/workspace_list_result.refused.json +26 -0
- package/contracts/wire-vectors/workspace_release.by-path-delete-branch.json +22 -0
- package/contracts/wire-vectors/workspace_release.request.json +22 -0
- package/contracts/wire-vectors/workspace_release_bulk.two-entries.json +32 -0
- package/contracts/wire-vectors/workspace_release_bulk_result.mixed.json +37 -0
- package/contracts/wire-vectors/workspace_release_result.partial.json +26 -0
- package/contracts/wire-vectors/workspace_release_result.refused.json +26 -0
- package/contracts/wire-vectors/workspace_release_result.released.json +23 -0
- package/package.json +6 -1
- package/src/bin/command.ts +198 -0
- package/src/bin/config.ts +104 -0
- package/src/bin/login.ts +149 -0
- package/src/bin/main.ts +204 -0
- package/src/bin/pair.ts +246 -0
- package/src/bin/periscope.ts +3 -0
- package/src/bin/posture.ts +232 -0
- package/src/bin/reconfigure.ts +266 -0
- package/src/bin/serve.ts +615 -0
- package/src/bin/status.ts +61 -0
- package/src/bin/workspaces.ts +280 -0
- package/src/control/backoff.ts +40 -0
- package/src/control/codec.ts +557 -0
- package/src/control/credential.ts +33 -0
- package/src/control/frames.ts +1586 -0
- package/src/control/link-state.ts +67 -0
- package/src/control/link.ts +894 -0
- package/src/control/queue.ts +248 -0
- package/src/control/seq.ts +76 -0
- package/src/control/stream-routing.ts +216 -0
- package/src/control/stream.ts +149 -0
- package/src/core/async-queue.ts +63 -0
- package/src/core/failure.ts +61 -0
- package/src/core/index.ts +22 -0
- package/src/core/keyed-turns.ts +47 -0
- package/src/core/paths.ts +84 -0
- package/src/core/refusal.ts +430 -0
- package/src/core/result.ts +27 -0
- package/src/core/time.ts +34 -0
- package/src/core/vocab.ts +44 -0
- package/src/core/workspace-id.ts +11 -0
- package/src/gate/command.ts +617 -0
- package/src/gate/decision.ts +173 -0
- package/src/gate/escalate.ts +164 -0
- package/src/gate/gate.ts +635 -0
- package/src/gate/index.ts +53 -0
- package/src/gate/jail.ts +210 -0
- package/src/gate/local.ts +115 -0
- package/src/gate/outcome.ts +164 -0
- package/src/gate/shell.ts +295 -0
- package/src/host/agent-process.ts +694 -0
- package/src/host/bulk-post.ts +166 -0
- package/src/host/claude-transcripts.ts +404 -0
- package/src/host/config-file.ts +246 -0
- package/src/host/git-facts.ts +119 -0
- package/src/host/hooks.ts +102 -0
- package/src/host/host.ts +1901 -0
- package/src/host/index.ts +92 -0
- package/src/host/link-state-file.ts +79 -0
- package/src/host/loopback.ts +177 -0
- package/src/host/machine.ts +27 -0
- package/src/host/mcp-server.ts +66 -0
- package/src/host/package-facts.ts +25 -0
- package/src/host/paired-credential-store.ts +140 -0
- package/src/host/paths.ts +175 -0
- package/src/host/repository-read.ts +245 -0
- package/src/host/session-store.ts +133 -0
- package/src/host/sign-in.ts +352 -0
- package/src/host/telemetry.ts +105 -0
- package/src/host/token-cache.ts +214 -0
- package/src/host/transcript-fs.ts +110 -0
- package/src/host/wire-request.ts +285 -0
- package/src/host/workspace-fs.ts +90 -0
- package/src/host/workspace-trust.ts +92 -0
- package/src/identity/authorize.ts +127 -0
- package/src/identity/config.ts +227 -0
- package/src/identity/credential.ts +176 -0
- package/src/identity/device-code.ts +217 -0
- package/src/identity/index.ts +84 -0
- package/src/identity/mode.ts +161 -0
- package/src/identity/paired-credential.ts +89 -0
- package/src/identity/pkce.ts +113 -0
- package/src/identity/store.ts +98 -0
- package/src/identity/token.ts +203 -0
- package/src/index.ts +140 -0
- package/src/mcp/descriptor.ts +75 -0
- package/src/mcp/index.ts +23 -0
- package/src/mcp/schema.ts +214 -0
- package/src/mcp/server.ts +180 -0
- package/src/persistence/entry.ts +142 -0
- package/src/persistence/index.ts +47 -0
- package/src/persistence/key.ts +77 -0
- package/src/persistence/mirror.ts +118 -0
- package/src/persistence/receipt.ts +182 -0
- package/src/persistence/retention.ts +118 -0
- package/src/persistence/store.ts +183 -0
- package/src/persistence/transition-log.ts +176 -0
- package/src/protocol.ts +215 -0
- package/src/sessions/index.ts +27 -0
- package/src/sessions/registry.ts +392 -0
- package/src/sessions/session.ts +440 -0
- package/src/sessions/spawn-env.ts +318 -0
- package/src/state/coverage.ts +360 -0
- package/src/state/index.ts +58 -0
- package/src/state/machine.ts +359 -0
- package/src/state/model.ts +417 -0
- package/src/state/observer.ts +499 -0
- package/src/state/reporter.ts +60 -0
- package/src/state/store.ts +157 -0
- package/src/telemetry/index.ts +10 -0
- package/src/telemetry/usage.ts +117 -0
- package/src/workspace/git-worktree.ts +551 -0
- package/src/workspace/index.ts +17 -0
- package/src/workspace/plain-dir.ts +132 -0
- package/src/workspace/provider.ts +159 -0
- package/src/workspace/worktree-porcelain.ts +118 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The device-code fallback — for a box with no browser on it, which for this package is not an edge
|
|
3
|
+
* case but a deployment target.
|
|
4
|
+
*
|
|
5
|
+
* It is off unless explicitly configured, and it is never reached by falling back. Microsoft's
|
|
6
|
+
* guidance classes device code as a high-risk flow that phishing campaigns exploit and recommends
|
|
7
|
+
* that organisations block it (the Conditional Access authentication-flows documentation,
|
|
8
|
+
* https://learn.microsoft.com/entra/identity/conditional-access/concept-authentication-flows#device-code-flow);
|
|
9
|
+
* it is also exactly what workplace Conditional Access
|
|
10
|
+
* policies block, so a tenant refusing it outright is an expected outcome rather than a bug. A flow
|
|
11
|
+
* with that standing must be asked for by name — silently falling back to it when the loopback
|
|
12
|
+
* listener cannot bind would be the host quietly choosing the riskier option on the operator's
|
|
13
|
+
* behalf, at the moment they are least able to notice.
|
|
14
|
+
*
|
|
15
|
+
* And the trap that is invisible for weeks. A provider that tracks which protocol issued a token
|
|
16
|
+
* marks a refresh token obtained through device code as such, and a later non-device-code refresh
|
|
17
|
+
* against it fails with `AADSTS530036`; the provider's guidance is that such a token is unusable
|
|
18
|
+
* for good and should be deleted.
|
|
19
|
+
* The failure does not appear at sign-in; it appears the first time the token is refreshed, which
|
|
20
|
+
* for an unattended host is hours or weeks later and nowhere near the cause. So a device-code
|
|
21
|
+
* sign-in poisons the cache for any other flow: if this
|
|
22
|
+
* host ever switches a deployment back to loopback, the cached material must be discarded, not
|
|
23
|
+
* refreshed. `discardOnProtocolMismatch` is how that is decided rather than discovered.
|
|
24
|
+
*/
|
|
25
|
+
import type { Refusal } from '../core/refusal.js';
|
|
26
|
+
import { refusal } from '../core/refusal.js';
|
|
27
|
+
import type { Result } from '../core/result.js';
|
|
28
|
+
import { ok, refuse } from '../core/result.js';
|
|
29
|
+
import type { IdentityConfig } from './config.js';
|
|
30
|
+
import { redactSecrets, secretsIn } from './token.js';
|
|
31
|
+
|
|
32
|
+
/** Which flow minted the material this host holds. Recorded so the trap above is decidable. */
|
|
33
|
+
export type AuthProtocol = 'loopback' | 'device-code';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The error code the provider returns when a Conditional Access authentication-flows policy
|
|
37
|
+
* rejects a refresh. Named so a reader can search for it.
|
|
38
|
+
*
|
|
39
|
+
* It is not a cross-protocol error, and naming it as one names the wrong cause. `AADSTS530036` is
|
|
40
|
+
* not "you refreshed device-code material with a different flow". Microsoft's documentation
|
|
41
|
+
* describes it as a Conditional Access authentication-flows check refusing the refresh token, and
|
|
42
|
+
* because such a policy applies to every application the token is unusable for good and should be
|
|
43
|
+
* deleted. The mechanism is protocol tracking: a session established with device code stays marked
|
|
44
|
+
* through every later refresh, and the tenant's policy then rejects it, so the trigger is an
|
|
45
|
+
* administrator enabling a policy, not this host being reconfigured. Source:
|
|
46
|
+
* https://learn.microsoft.com/entra/identity/conditional-access/concept-authentication-flows#device-code-flow
|
|
47
|
+
*
|
|
48
|
+
* A cross-protocol name would be dangerous rather than merely inaccurate: it would imply the fix
|
|
49
|
+
* is to sign in again, which re-runs the same flow into the same policy, forever. And it is the
|
|
50
|
+
* policy Microsoft actively recommends: allow device code only where a documented case needs it
|
|
51
|
+
* and block it everywhere else
|
|
52
|
+
* (https://learn.microsoft.com/entra/identity/conditional-access/policy-block-authentication-flows).
|
|
53
|
+
* See `host/sign-in.ts` for the recovery
|
|
54
|
+
* this routes to.
|
|
55
|
+
*/
|
|
56
|
+
export const AUTH_FLOW_BLOCKED_ERROR = 'AADSTS530036';
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Every provider code known to mean "a policy has blocked the flow this material was minted by".
|
|
60
|
+
* One today; a provider that reports the same condition under another code is added here, and
|
|
61
|
+
* only here: this file is the one place in the package that may know a provider's error codes.
|
|
62
|
+
*/
|
|
63
|
+
export const AUTH_FLOW_BLOCKED_CODES: readonly string[] = [AUTH_FLOW_BLOCKED_ERROR];
|
|
64
|
+
|
|
65
|
+
export interface DeviceAuthorization {
|
|
66
|
+
readonly deviceCode: string;
|
|
67
|
+
readonly userCode: string;
|
|
68
|
+
readonly verificationUri: string;
|
|
69
|
+
readonly expiresAt: number;
|
|
70
|
+
readonly intervalMs: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The provider's answer to one poll. */
|
|
74
|
+
export type DevicePollOutcome =
|
|
75
|
+
| { readonly kind: 'pending' }
|
|
76
|
+
/** The provider asked the caller to back off; the caller widens its interval by this much. */
|
|
77
|
+
| { readonly kind: 'slow-down'; readonly intervalMs: number }
|
|
78
|
+
| { readonly kind: 'ready' }
|
|
79
|
+
| { readonly kind: 'declined'; readonly refusal: Refusal };
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Refuse the device-code flow unless it was turned on.
|
|
83
|
+
*
|
|
84
|
+
* Exported and total so the refusal is reachable in a test rather than being a branch nobody drives.
|
|
85
|
+
*/
|
|
86
|
+
export function requireDeviceCodeEnabled(config: IdentityConfig): Result<true> {
|
|
87
|
+
if (!config.deviceCodeEnabled) {
|
|
88
|
+
return refuse(
|
|
89
|
+
'device-code-not-enabled',
|
|
90
|
+
'the device-code flow is not enabled on this host; set PERISCOPE_IDENTITY_DEVICE_CODE=1 to allow it, and expect some tenants to refuse it by policy',
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
return ok(true);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function deviceAuthorizationBody(config: IdentityConfig): string {
|
|
97
|
+
const body = new URLSearchParams();
|
|
98
|
+
body.set('client_id', config.clientId);
|
|
99
|
+
body.set('scope', config.scopes.join(' '));
|
|
100
|
+
return body.toString();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function devicePollBody(config: IdentityConfig, deviceCode: string): string {
|
|
104
|
+
const body = new URLSearchParams();
|
|
105
|
+
// RFC 8628's grant type, urn-namespaced.
|
|
106
|
+
body.set('grant_type', 'urn:ietf:params:oauth:grant-type:device_code');
|
|
107
|
+
body.set('client_id', config.clientId);
|
|
108
|
+
body.set('device_code', deviceCode);
|
|
109
|
+
return body.toString();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function readDeviceAuthorization(
|
|
113
|
+
status: number,
|
|
114
|
+
body: unknown,
|
|
115
|
+
nowMs: number,
|
|
116
|
+
): Result<DeviceAuthorization> {
|
|
117
|
+
const secrets = secretsIn(body);
|
|
118
|
+
if (typeof body !== 'object' || body === null) {
|
|
119
|
+
return refuse(
|
|
120
|
+
'device-code-declined',
|
|
121
|
+
`the device authorization endpoint returned HTTP ${status} with no JSON body`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
const record = body as Record<string, unknown>;
|
|
125
|
+
|
|
126
|
+
if (status < 200 || status >= 300) {
|
|
127
|
+
const error = typeof record['error'] === 'string' ? record['error'] : `HTTP ${status}`;
|
|
128
|
+
return refuse(
|
|
129
|
+
'device-code-declined',
|
|
130
|
+
redactSecrets(
|
|
131
|
+
`the device authorization endpoint refused: ${error} — some tenants block this flow by policy`,
|
|
132
|
+
secrets,
|
|
133
|
+
),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const deviceCode = record['device_code'];
|
|
138
|
+
const userCode = record['user_code'];
|
|
139
|
+
const verificationUri = record['verification_uri'] ?? record['verification_url'];
|
|
140
|
+
const expiresIn = Number(record['expires_in']);
|
|
141
|
+
const interval = Number(record['interval']);
|
|
142
|
+
|
|
143
|
+
if (typeof deviceCode !== 'string' || typeof userCode !== 'string' || typeof verificationUri !== 'string') {
|
|
144
|
+
return refuse(
|
|
145
|
+
'device-code-declined',
|
|
146
|
+
'the device authorization response is missing device_code, user_code or verification_uri',
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
if (!Number.isFinite(expiresIn) || expiresIn <= 0) {
|
|
150
|
+
return refuse('device-code-declined', 'the device authorization response carries no usable expires_in');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return ok({
|
|
154
|
+
deviceCode,
|
|
155
|
+
userCode,
|
|
156
|
+
verificationUri,
|
|
157
|
+
expiresAt: nowMs + expiresIn * 1000,
|
|
158
|
+
// RFC 8628 says default to 5 seconds when the provider does not state an interval.
|
|
159
|
+
intervalMs: (Number.isFinite(interval) && interval > 0 ? interval : 5) * 1000,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Read one poll response.
|
|
165
|
+
*
|
|
166
|
+
* `authorization_pending` and `slow_down` are not failures — they are the protocol working. Mapping
|
|
167
|
+
* them onto a refusal would end a sign-in that was proceeding normally.
|
|
168
|
+
*/
|
|
169
|
+
export function readDevicePoll(status: number, body: unknown, currentIntervalMs: number): DevicePollOutcome {
|
|
170
|
+
if (status >= 200 && status < 300) return { kind: 'ready' };
|
|
171
|
+
|
|
172
|
+
const record = typeof body === 'object' && body !== null ? (body as Record<string, unknown>) : {};
|
|
173
|
+
const error = typeof record['error'] === 'string' ? record['error'] : `http_${status}`;
|
|
174
|
+
|
|
175
|
+
if (error === 'authorization_pending') return { kind: 'pending' };
|
|
176
|
+
if (error === 'slow_down') return { kind: 'slow-down', intervalMs: currentIntervalMs + 5_000 };
|
|
177
|
+
|
|
178
|
+
const description = typeof record['error_description'] === 'string' ? record['error_description'] : null;
|
|
179
|
+
return {
|
|
180
|
+
kind: 'declined',
|
|
181
|
+
refusal: refusal(
|
|
182
|
+
'device-code-declined',
|
|
183
|
+
redactSecrets(
|
|
184
|
+
description === null
|
|
185
|
+
? `the device-code sign-in ended: ${error}`
|
|
186
|
+
: `the device-code sign-in ended: ${error} — ${description}`,
|
|
187
|
+
secretsIn(body),
|
|
188
|
+
),
|
|
189
|
+
),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Must cached material be discarded rather than refreshed, because this host was reconfigured?
|
|
195
|
+
*
|
|
196
|
+
* This is not the `AADSTS530036` guard. It detects one real but narrow
|
|
197
|
+
* case: the cache was minted by one flow and the host is now configured for the other, so a refresh
|
|
198
|
+
* would fail for a reason nobody would connect to a sign-in that happened weeks ago.
|
|
199
|
+
*
|
|
200
|
+
* It cannot fire on the policy case, which is the common one. When a tenant enables the
|
|
201
|
+
* authentication-flows policy, the host is still configured for device code and the cache was still
|
|
202
|
+
* minted by device code — `cached === configured`, so this returns `false` and nothing is discarded.
|
|
203
|
+
* A guard named for an error it is structurally unable to catch is worse than no guard, because it
|
|
204
|
+
* reads as coverage. The reactive path in `host/sign-in.ts` is what handles the policy case.
|
|
205
|
+
*/
|
|
206
|
+
export function discardOnProtocolMismatch(cached: AuthProtocol, configured: AuthProtocol): boolean {
|
|
207
|
+
return cached !== configured;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Does this provider error mean a Conditional Access authentication-flows policy has blocked this
|
|
212
|
+
* material permanently? Microsoft's documentation: "the token will never be usable and should be
|
|
213
|
+
* deleted."
|
|
214
|
+
*/
|
|
215
|
+
export function isAuthFlowBlocked(errorText: string): boolean {
|
|
216
|
+
return AUTH_FLOW_BLOCKED_CODES.some((code) => errorText.includes(code));
|
|
217
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identity — signing a real user in, holding what that produced, and presenting it.
|
|
3
|
+
*
|
|
4
|
+
* Every module behind this barrel is pure. The two impure halves live in `host/`
|
|
5
|
+
* (`token-cache.ts` writes the file, `loopback.ts` opens the port), which is where this package
|
|
6
|
+
* confines anything that touches the machine. That split is the same one the gate uses, and it is
|
|
7
|
+
* what makes the awkward branches — a malformed cache, a callback with the wrong `state`, a
|
|
8
|
+
* filesystem that ignores modes — drivable without a machine in that state.
|
|
9
|
+
*
|
|
10
|
+
* Nothing here names a provider. The authority, client and endpoints are configuration, and the
|
|
11
|
+
* flows are RFC 6749 / 7636 / 8628 as written. Any one commercial provider is a configuration of
|
|
12
|
+
* this, not the shape of it — which is what lets somebody point this at their own provider and
|
|
13
|
+
* have it work.
|
|
14
|
+
*/
|
|
15
|
+
export type { IdentityConfig, IdentityEndpoints, IdentityPosture } from './config.js';
|
|
16
|
+
export {
|
|
17
|
+
DEFAULT_SCOPES,
|
|
18
|
+
discoveryUrl,
|
|
19
|
+
identityPosture,
|
|
20
|
+
readDiscoveryDocument,
|
|
21
|
+
readIdentityConfig,
|
|
22
|
+
} from './config.js';
|
|
23
|
+
|
|
24
|
+
export type { Pkce } from './pkce.js';
|
|
25
|
+
export {
|
|
26
|
+
CODE_CHALLENGE_METHOD,
|
|
27
|
+
challengeFor,
|
|
28
|
+
createPkce,
|
|
29
|
+
createState,
|
|
30
|
+
randomUrlSafe,
|
|
31
|
+
requireS256,
|
|
32
|
+
stateMatches,
|
|
33
|
+
} from './pkce.js';
|
|
34
|
+
|
|
35
|
+
export type { AuthorizationCallback, AuthorizationRequest } from './authorize.js';
|
|
36
|
+
export {
|
|
37
|
+
CALLBACK_PAGE,
|
|
38
|
+
buildAuthorizationUrl,
|
|
39
|
+
readAuthorizationCallback,
|
|
40
|
+
redirectUriFor,
|
|
41
|
+
} from './authorize.js';
|
|
42
|
+
|
|
43
|
+
export type { TokenSet } from './token.js';
|
|
44
|
+
export {
|
|
45
|
+
EXPIRY_SKEW_MS,
|
|
46
|
+
authorizationValue,
|
|
47
|
+
codeExchangeBody,
|
|
48
|
+
isFresh,
|
|
49
|
+
readTokenResponse,
|
|
50
|
+
redactSecrets,
|
|
51
|
+
refreshBody,
|
|
52
|
+
secretsIn,
|
|
53
|
+
} from './token.js';
|
|
54
|
+
|
|
55
|
+
export type { CredentialModeOutcome, ModeEnforcement } from './mode.js';
|
|
56
|
+
export {
|
|
57
|
+
CREDENTIAL_MODE,
|
|
58
|
+
classifyCredentialMode,
|
|
59
|
+
classifyProbeReadings,
|
|
60
|
+
isWiderThan,
|
|
61
|
+
toOctal,
|
|
62
|
+
} from './mode.js';
|
|
63
|
+
|
|
64
|
+
export type { AuthProtocol, DeviceAuthorization, DevicePollOutcome } from './device-code.js';
|
|
65
|
+
export {
|
|
66
|
+
AUTH_FLOW_BLOCKED_CODES,
|
|
67
|
+
AUTH_FLOW_BLOCKED_ERROR,
|
|
68
|
+
deviceAuthorizationBody,
|
|
69
|
+
devicePollBody,
|
|
70
|
+
discardOnProtocolMismatch,
|
|
71
|
+
isAuthFlowBlocked,
|
|
72
|
+
readDeviceAuthorization,
|
|
73
|
+
readDevicePoll,
|
|
74
|
+
requireDeviceCodeEnabled,
|
|
75
|
+
} from './device-code.js';
|
|
76
|
+
|
|
77
|
+
export type { CachedTokens, TokenStore } from './store.js';
|
|
78
|
+
export { readCachedTokens } from './store.js';
|
|
79
|
+
|
|
80
|
+
export type { TokenCredentialOptions, TokenRefresher } from './credential.js';
|
|
81
|
+
export { AUTHORIZATION_HEADER, TokenCredential } from './credential.js';
|
|
82
|
+
|
|
83
|
+
export type { PairedCredentialFile, PairedCredentialStore } from './paired-credential.js';
|
|
84
|
+
export { PairedHostCredential, readPairedCredentialFile } from './paired-credential.js';
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the token file on disk is actually as private as requested — decided here, purely, so
|
|
3
|
+
* every branch can be driven on any platform.
|
|
4
|
+
*
|
|
5
|
+
* The defect, observed on win32 rather than reasoned. On win32, `node` accepts a `0o600` mode and
|
|
6
|
+
* does nothing with it: writing with `{mode: 0o600}` and then `chmod`ing to `0o600` both leave
|
|
7
|
+
* `statSync().mode & 0o777` reading `0o666`, and a deliberately world-readable `0o666` file reads
|
|
8
|
+
* exactly the same. So on Windows a restricted token file and an unrestricted one are
|
|
9
|
+
* indistinguishable through the only API available.
|
|
10
|
+
*
|
|
11
|
+
* That makes the obvious implementation wrong in both directions, which is why this is a module and
|
|
12
|
+
* not an `if`:
|
|
13
|
+
*
|
|
14
|
+
* - Verify literally (`observed === 0o600`) and the host refuses every write on Windows — it
|
|
15
|
+
* would be unusable there.
|
|
16
|
+
* - Relax it to "not world-writable" and the check passes vacuously on Windows while proving
|
|
17
|
+
* nothing at all — a green that reads as evidence.
|
|
18
|
+
*
|
|
19
|
+
* So the third answer is the only honest one: where modes are enforced, verify and refuse if the
|
|
20
|
+
* file came out wider than asked. Where they are not, say so by name as a degrade. A degrade is
|
|
21
|
+
* a named outcome, never a silent pass — and "this could not be confirmed" is a fact an operator
|
|
22
|
+
* can act on, while a checkmark that means nothing is not.
|
|
23
|
+
*
|
|
24
|
+
* Enforcement is measured, not assumed from `process.platform`. The caller probes it (see
|
|
25
|
+
* `host/token-cache.ts`) and passes the answer in. A platform list would be a guess that ages;
|
|
26
|
+
* WSL, a POSIX filesystem mounted on Windows, or a future runtime that maps modes onto ACLs would
|
|
27
|
+
* each make the guess wrong in the direction that matters.
|
|
28
|
+
*
|
|
29
|
+
* And the measurement has an inconclusive state, which is why this is a three-way enum and not a
|
|
30
|
+
* boolean. A probe that reports "not enforced" is only worth anything if it could have reported
|
|
31
|
+
* something else. On win32, `chmod 0444` does read back as `444`, so the write bit is real even
|
|
32
|
+
* though the read/group/other bits are fabricated as `6`. That gives the probe a positive control
|
|
33
|
+
* — it can demonstrate it observes something before it claims it cannot observe privacy.
|
|
34
|
+
* Without that, "unenforced" and "my probe is broken" are the same answer, and an instrument with
|
|
35
|
+
* no inconclusive state reports confidence it has not earned.
|
|
36
|
+
*/
|
|
37
|
+
import type { Refusal } from '../core/refusal.js';
|
|
38
|
+
import { refusal } from '../core/refusal.js';
|
|
39
|
+
|
|
40
|
+
/** The mode credential material is written with, everywhere. */
|
|
41
|
+
export const CREDENTIAL_MODE = 0o600;
|
|
42
|
+
|
|
43
|
+
const PERMISSION_BITS = 0o777;
|
|
44
|
+
|
|
45
|
+
/** What the filesystem was measured to do with a mode. */
|
|
46
|
+
export type ModeEnforcement =
|
|
47
|
+
/** POSIX modes are honoured: what was asked for is what came back, and wide differs from narrow. */
|
|
48
|
+
| 'enforced'
|
|
49
|
+
/**
|
|
50
|
+
* Measured, and the answer is that only the write bit survives — the win32 shape. `0600` and
|
|
51
|
+
* `0666` are indistinguishable, so privacy cannot be confirmed; but `0444` reads back as `444`,
|
|
52
|
+
* so the instrument is live and this is a real finding rather than a dead probe.
|
|
53
|
+
*/
|
|
54
|
+
| 'write-bit-only'
|
|
55
|
+
/**
|
|
56
|
+
* Inconclusive. No mode change was observable at all, not even clearing the write bit. The
|
|
57
|
+
* probe cannot distinguish "this filesystem records nothing" from "this probe is broken", and
|
|
58
|
+
* saying so is the only honest option.
|
|
59
|
+
*/
|
|
60
|
+
| 'unobservable';
|
|
61
|
+
|
|
62
|
+
export type CredentialModeOutcome =
|
|
63
|
+
/** Modes are enforced here and the file is exactly as private as requested. */
|
|
64
|
+
| { readonly kind: 'verified'; readonly observed: number }
|
|
65
|
+
/** Modes are enforced here and the file is readable or writable by more than its owner. */
|
|
66
|
+
| { readonly kind: 'too-wide'; readonly observed: number; readonly refusal: Refusal }
|
|
67
|
+
/** Not a failure: privacy is unconfirmable here, and that was measured rather than assumed. */
|
|
68
|
+
| { readonly kind: 'unenforced'; readonly observed: number; readonly refusal: Refusal }
|
|
69
|
+
/** Not even a measurement: the instrument could observe nothing. Worth investigating. */
|
|
70
|
+
| { readonly kind: 'unobservable'; readonly observed: number; readonly refusal: Refusal };
|
|
71
|
+
|
|
72
|
+
/** Is any permission bit set that was not requested? */
|
|
73
|
+
export function isWiderThan(observed: number, requested: number): boolean {
|
|
74
|
+
return (observed & ~requested & PERMISSION_BITS) !== 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Decide what three probe readings mean. Pure, so the shapes this machine cannot produce are still
|
|
79
|
+
* drivable.
|
|
80
|
+
*
|
|
81
|
+
* This function exists because the obvious control is vacuous. Without the `0444` reading, on
|
|
82
|
+
* win32 a probe that runs the control and a probe that merely assumes its answer both report
|
|
83
|
+
* `write-bit-only`, so nothing on that machine can tell them apart. What is uniquely lost is the
|
|
84
|
+
* ability to distinguish a filesystem that records only the write bit from one that records
|
|
85
|
+
* nothing — and that difference only shows up on a filesystem a win32 machine cannot produce on
|
|
86
|
+
* demand. Stating the readings as data is what makes it testable at all.
|
|
87
|
+
*
|
|
88
|
+
* narrow what `stat` reported after asking for `requested` (0600)
|
|
89
|
+
* wide what it reported after asking for 0666
|
|
90
|
+
* readOnly what it reported after clearing the write bit (0444) — the positive control
|
|
91
|
+
*/
|
|
92
|
+
export function classifyProbeReadings(
|
|
93
|
+
narrow: number,
|
|
94
|
+
wide: number,
|
|
95
|
+
readOnly: number,
|
|
96
|
+
requested: number = CREDENTIAL_MODE,
|
|
97
|
+
): ModeEnforcement {
|
|
98
|
+
const n = narrow & PERMISSION_BITS;
|
|
99
|
+
const w = wide & PERMISSION_BITS;
|
|
100
|
+
const r = readOnly & PERMISSION_BITS;
|
|
101
|
+
|
|
102
|
+
// Full POSIX: what was asked for came back, and asking for something wider changed the answer.
|
|
103
|
+
if (n === (requested & PERMISSION_BITS) && n !== w) return 'enforced';
|
|
104
|
+
|
|
105
|
+
// Privacy is unconfirmable. Before saying so, the instrument must have shown it observes
|
|
106
|
+
// something — otherwise "unenforced" is indistinguishable from a dead probe.
|
|
107
|
+
return r !== w ? 'write-bit-only' : 'unobservable';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Classify what a verify-after-write actually found.
|
|
112
|
+
*
|
|
113
|
+
* `enforcement` is the measured answer to "what does this filesystem do with a mode at all".
|
|
114
|
+
*/
|
|
115
|
+
export function classifyCredentialMode(
|
|
116
|
+
observed: number,
|
|
117
|
+
enforcement: ModeEnforcement,
|
|
118
|
+
requested: number = CREDENTIAL_MODE,
|
|
119
|
+
): CredentialModeOutcome {
|
|
120
|
+
const bits = observed & PERMISSION_BITS;
|
|
121
|
+
|
|
122
|
+
if (enforcement === 'unobservable') {
|
|
123
|
+
return {
|
|
124
|
+
kind: 'unobservable',
|
|
125
|
+
observed: bits,
|
|
126
|
+
refusal: refusal(
|
|
127
|
+
'credential-mode-unobservable',
|
|
128
|
+
`mode ${toOctal(requested)} was requested and this filesystem reported no observable mode change of any kind — not even clearing the write bit — so nothing at all can be said about the token file's permissions here; this is an inconclusive instrument rather than a finding about privacy`,
|
|
129
|
+
),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (enforcement === 'write-bit-only') {
|
|
134
|
+
return {
|
|
135
|
+
kind: 'unenforced',
|
|
136
|
+
observed: bits,
|
|
137
|
+
refusal: refusal(
|
|
138
|
+
'credential-mode-unenforced',
|
|
139
|
+
`mode ${toOctal(requested)} was requested and this filesystem records only the write bit (it reports ${toOctal(bits)} here, and cannot distinguish owner-only from world-readable), so the token file's privacy cannot be confirmed — it is protected by the gate's credential-path denial and by the OS account, not by this mode. The probe did observe a mode change when the write bit was cleared, so this is a measurement rather than a dead check`,
|
|
140
|
+
),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (isWiderThan(bits, requested)) {
|
|
145
|
+
return {
|
|
146
|
+
kind: 'too-wide',
|
|
147
|
+
observed: bits,
|
|
148
|
+
refusal: refusal(
|
|
149
|
+
'credential-mode-too-wide',
|
|
150
|
+
`the token file was written with mode ${toOctal(bits)} after ${toOctal(requested)} was requested, so it is readable or writable by more than its owner`,
|
|
151
|
+
),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { kind: 'verified', observed: bits };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** `0o600`, for a message a human reads. */
|
|
159
|
+
export function toOctal(mode: number): string {
|
|
160
|
+
return `0${(mode & PERMISSION_BITS).toString(8)}`;
|
|
161
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The paired-host credential — the durable identity a machine holds after `periscope pair`.
|
|
3
|
+
*
|
|
4
|
+
* The shape and its validation live here, not with the filesystem — the same split `store.ts`
|
|
5
|
+
* states for the token cache, for the same reason: `host/` may import this, and this may never
|
|
6
|
+
* import `host/`.
|
|
7
|
+
*
|
|
8
|
+
* This is not the OIDC token and not the RFC 8628 `device-code` flow, and the noun is chosen to
|
|
9
|
+
* keep the three apart. A paired credential is an opaque controller-minted bearer
|
|
10
|
+
* (`p1.<hostId>.<secret>`): it never expires on a clock, never refreshes, and dies only when the
|
|
11
|
+
* controller revokes it — at which point the upgrade answers 401 and the host's terminal path
|
|
12
|
+
* names it. That is what makes it survive the idle window after which a provider may let an OIDC
|
|
13
|
+
* refresh token lapse.
|
|
14
|
+
*
|
|
15
|
+
* It carries no user, and cannot. The controller derives the acting user from its own registry
|
|
16
|
+
* row; a host presenting this credential asserts nothing about who it acts for, which is the whole
|
|
17
|
+
* design — nothing in this file could smuggle an identity claim even by mistake, because there is
|
|
18
|
+
* no field to put one in.
|
|
19
|
+
*/
|
|
20
|
+
import type { Authorization, ControllerCredential } from '../control/credential.js';
|
|
21
|
+
import type { Result } from '../core/result.js';
|
|
22
|
+
import { ok, refuse } from '../core/result.js';
|
|
23
|
+
import { AUTHORIZATION_HEADER } from './credential.js';
|
|
24
|
+
|
|
25
|
+
/** The persisted record — what `periscope pair` writes and the daemon reads. */
|
|
26
|
+
export interface PairedCredentialFile {
|
|
27
|
+
/** The host id the credential names. The daemon announces this id in `link_hello` — the
|
|
28
|
+
* controller refuses a hello naming anything else, so the file's copy is load-bearing. */
|
|
29
|
+
readonly hostId: string;
|
|
30
|
+
/** The full opaque credential, exactly as the controller minted it. */
|
|
31
|
+
readonly credential: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The port the pair verb writes through and the daemon reads through — an interface for the same
|
|
35
|
+
* reason `TokenStore` is one: every malformed-file branch is drivable without a disk. */
|
|
36
|
+
export interface PairedCredentialStore {
|
|
37
|
+
read(): Result<PairedCredentialFile>;
|
|
38
|
+
write(file: PairedCredentialFile): Result<unknown>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Validate a parsed file into the shape the daemon relies on. */
|
|
42
|
+
export function readPairedCredentialFile(parsed: unknown): Result<PairedCredentialFile> {
|
|
43
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
44
|
+
return refuse('credential-cache-unreadable', 'the paired credential file is not a JSON object');
|
|
45
|
+
}
|
|
46
|
+
const record = parsed as Record<string, unknown>;
|
|
47
|
+
const hostId = record['hostId'];
|
|
48
|
+
const credential = record['credential'];
|
|
49
|
+
|
|
50
|
+
if (typeof hostId !== 'string' || hostId === '') {
|
|
51
|
+
return refuse('credential-cache-unreadable', 'the paired credential file names no hostId');
|
|
52
|
+
}
|
|
53
|
+
if (typeof credential !== 'string' || credential === '') {
|
|
54
|
+
return refuse('credential-cache-unreadable', 'the paired credential file carries no credential');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The credential embeds the host id it speaks for; a file whose two copies disagree would make
|
|
58
|
+
// this host announce one identity and authenticate as another — the exact mismatch the
|
|
59
|
+
// controller's hello binding exists to refuse. Caught here, where the fix (re-pair) is printable.
|
|
60
|
+
if (!credential.startsWith(`p1.${hostId}.`)) {
|
|
61
|
+
return refuse(
|
|
62
|
+
'credential-cache-unreadable',
|
|
63
|
+
'the paired credential does not match the hostId beside it — the file is corrupt; run: periscope pair <code>',
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return ok({ hostId, credential });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The `ControllerCredential` a paired host presents — on the link, the decision POST and the bulk
|
|
72
|
+
* POST alike, through the one spread point in the composition root.
|
|
73
|
+
*
|
|
74
|
+
* No refresh, no expiry, no reporter, and each absence is the design: the credential is a stable
|
|
75
|
+
* secret whose validity lives server-side, so there is exactly one outcome here and nothing to
|
|
76
|
+
* distinguish. Revocation surfaces as the controller's 401, which the link already classifies as
|
|
77
|
+
* terminal `link-unauthorized` — this class cannot see it coming and must not pretend to.
|
|
78
|
+
*/
|
|
79
|
+
export class PairedHostCredential implements ControllerCredential {
|
|
80
|
+
readonly #value: string;
|
|
81
|
+
|
|
82
|
+
constructor(file: PairedCredentialFile) {
|
|
83
|
+
this.#value = `Bearer ${file.credential}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
authorize(): Promise<Result<Authorization>> {
|
|
87
|
+
return Promise.resolve(ok({ header: AUTHORIZATION_HEADER, value: this.#value }));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PKCE (RFC 7636) and the callback's `state` — the two random values that make an authorization-code
|
|
3
|
+
* exchange safe to run on a machine somebody else is also running code on.
|
|
4
|
+
*
|
|
5
|
+
* They defend different things and neither substitutes for the other. This is the sentence worth
|
|
6
|
+
* reading twice, because "it does PKCE" is routinely taken to mean the whole callback is covered:
|
|
7
|
+
*
|
|
8
|
+
* - The code verifier proves that whoever redeems the authorization code is the same party
|
|
9
|
+
* that requested it. It protects the code exchange.
|
|
10
|
+
* - The state proves that the callback this host received is the answer to the request this
|
|
11
|
+
* host made. It protects the callback itself.
|
|
12
|
+
*
|
|
13
|
+
* Why that matters acutely here, rather than as boilerplate. This host's whole premise is that
|
|
14
|
+
* the agent runs as the same OS user as the host — it is why an 0600 token file is not a boundary
|
|
15
|
+
* and why the gate has a credential-path denial. That same fact means any local process can
|
|
16
|
+
* connect to the loopback listener this flow opens. Without `state`, such a process could hand this
|
|
17
|
+
* host an authorization code of its own obtaining and have the host redeem it and cache the
|
|
18
|
+
* resulting token. `state` is what makes that a named refusal instead of a silent substitution.
|
|
19
|
+
*/
|
|
20
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
21
|
+
|
|
22
|
+
import type { Result } from '../core/result.js';
|
|
23
|
+
import { ok, refuse } from '../core/result.js';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The only challenge method this host will use.
|
|
27
|
+
*
|
|
28
|
+
* RFC 7636 also defines `plain`, where the challenge is the verifier. This host refuses it rather
|
|
29
|
+
* than merely not offering it — an unused branch is one a later reader restores on a compatibility
|
|
30
|
+
* report, and `plain` gives away the whole property to anyone who can observe the authorization
|
|
31
|
+
* request.
|
|
32
|
+
*/
|
|
33
|
+
export const CODE_CHALLENGE_METHOD = 'S256';
|
|
34
|
+
|
|
35
|
+
/** 32 bytes, which is 43 base64url characters — the RFC's floor is 43 and its ceiling is 128. */
|
|
36
|
+
const ENTROPY_BYTES = 32;
|
|
37
|
+
|
|
38
|
+
export interface Pkce {
|
|
39
|
+
readonly verifier: string;
|
|
40
|
+
readonly challenge: string;
|
|
41
|
+
readonly method: typeof CODE_CHALLENGE_METHOD;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* base64url, per RFC 4648 §5 — no padding, URL-safe alphabet.
|
|
46
|
+
*
|
|
47
|
+
* Hand-rolled from base64 rather than reached for by name: `Buffer`'s `base64url` encoding is
|
|
48
|
+
* equivalent, but the transform is one line and stating it keeps this readable next to the RFC.
|
|
49
|
+
*/
|
|
50
|
+
function base64Url(bytes: Buffer): string {
|
|
51
|
+
return bytes.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A cryptographically random, URL-safe string.
|
|
56
|
+
*
|
|
57
|
+
* `randomBytes`, never `Math.random`. `Math.random` is seeded, predictable and explicitly not for
|
|
58
|
+
* this; a predictable verifier or state defeats the property entirely while looking identical in
|
|
59
|
+
* every test.
|
|
60
|
+
*/
|
|
61
|
+
export function randomUrlSafe(bytes: number = ENTROPY_BYTES): string {
|
|
62
|
+
return base64Url(randomBytes(bytes));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A fresh verifier and its S256 challenge. */
|
|
66
|
+
export function createPkce(): Pkce {
|
|
67
|
+
const verifier = randomUrlSafe();
|
|
68
|
+
return { verifier, challenge: challengeFor(verifier), method: CODE_CHALLENGE_METHOD };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The S256 challenge for a verifier: base64url(SHA-256(ascii(verifier))). */
|
|
72
|
+
export function challengeFor(verifier: string): string {
|
|
73
|
+
return base64Url(createHash('sha256').update(verifier, 'ascii').digest());
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The `state` value for one authorization request.
|
|
78
|
+
*
|
|
79
|
+
* Same entropy source as the verifier and deliberately a separate value — reusing the verifier as
|
|
80
|
+
* state would put it in a URL the browser and the provider both log, and the verifier is the one
|
|
81
|
+
* that must never leave this process until the token exchange.
|
|
82
|
+
*/
|
|
83
|
+
export function createState(): string {
|
|
84
|
+
return randomUrlSafe();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Refuse a challenge method this host will not use.
|
|
89
|
+
*
|
|
90
|
+
* Exported so the refusal is reachable and testable, rather than being an `if` nobody can drive.
|
|
91
|
+
*/
|
|
92
|
+
export function requireS256(method: string): Result<typeof CODE_CHALLENGE_METHOD> {
|
|
93
|
+
if (method !== CODE_CHALLENGE_METHOD) {
|
|
94
|
+
return refuse(
|
|
95
|
+
'pkce-method-unsupported',
|
|
96
|
+
`code challenge method ${method} is refused; this host uses ${CODE_CHALLENGE_METHOD} only, and "plain" would put the verifier itself in the authorization request`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return ok(CODE_CHALLENGE_METHOD);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Compare two `state` values without leaking their contents through timing.
|
|
104
|
+
*
|
|
105
|
+
* The length check is separate and deliberate: `timingSafeEqual` throws on unequal lengths, so
|
|
106
|
+
* comparing lengths first is required rather than an optimisation. It leaks the length and nothing
|
|
107
|
+
* else, which is the standard accepted residual.
|
|
108
|
+
*/
|
|
109
|
+
export function stateMatches(expected: string, received: string): boolean {
|
|
110
|
+
const a = Buffer.from(expected, 'utf8');
|
|
111
|
+
const b = Buffer.from(received, 'utf8');
|
|
112
|
+
return a.length === b.length && a.length > 0 && timingSafeEqual(a, b);
|
|
113
|
+
}
|