@elixpo/lixblogs-cli 1.1.2

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,90 @@
1
+ /**
2
+ * AuthProvider — the single interface every auth implementation must satisfy.
3
+ *
4
+ * No CLI or API code should ever call a provider-specific method directly.
5
+ * Everything goes through this interface, so swapping MockAuthProvider for
6
+ * the real ElixpoAuthProvider (once accounts.elixpo.com confirms device-flow
7
+ * support) requires no changes to calling code.
8
+ *
9
+ * See: elixpo/blogs.elixpo#137 for the full requirements this interface
10
+ * exists to satisfy.
11
+ */
12
+
13
+ /**
14
+ * @typedef {Object} DeviceCodeResponse
15
+ * @property {string} deviceCode - Opaque code the client polls with.
16
+ * @property {string} userCode - Short code the user enters at verificationUri.
17
+ * @property {string} verificationUri - URL the user visits to approve login.
18
+ * @property {string} [verificationUriComplete] - Approval URL with user code pre-filled.
19
+ * @property {number} expiresInSeconds - How long the device code is valid for.
20
+ * @property {number} pollIntervalSeconds - Minimum seconds between poll attempts.
21
+ */
22
+
23
+ /**
24
+ * @typedef {Object} TokenResponse
25
+ * @property {string} accessToken
26
+ * @property {string} refreshToken
27
+ * @property {number} expiresInSeconds
28
+ * @property {string[]} scopes - Scopes actually granted (may be a subset of requested).
29
+ */
30
+
31
+ /**
32
+ * @typedef {"pending"|"approved"|"denied"|"expired"|"slow_down"} PollStatus
33
+ */
34
+
35
+ /**
36
+ * @typedef {Object} PollResult
37
+ * @property {PollStatus} status
38
+ * @property {TokenResponse} [token] - Present only when status === "approved".
39
+ * @property {number} [pollIntervalIncreaseSeconds] - Present only when
40
+ * status === "slow_down". Caller must add this to its current polling
41
+ * interval before the next poll (RFC 8628 §3.5 behavior).
42
+ */
43
+
44
+ export class AuthProvider {
45
+ /**
46
+ * Identifies which implementation this is. Used by the production safety
47
+ * gate to refuse anything but the approved provider in production.
48
+ * @returns {string} e.g. "mock" | "elixpo"
49
+ */
50
+ get providerId() {
51
+ throw new Error("AuthProvider.providerId must be implemented by subclass");
52
+ }
53
+
54
+ /**
55
+ * Start a device authorization request.
56
+ * @param {{ scopes: string[] }} params
57
+ * @returns {Promise<DeviceCodeResponse>}
58
+ */
59
+ async requestDeviceCode(_params) {
60
+ throw new Error("AuthProvider.requestDeviceCode must be implemented by subclass");
61
+ }
62
+
63
+ /**
64
+ * Poll for whether the user has approved/denied the device code yet.
65
+ * Callers are responsible for respecting pollIntervalSeconds between calls.
66
+ * @param {{ deviceCode: string }} params
67
+ * @returns {Promise<PollResult>}
68
+ */
69
+ async pollDeviceCode(_params) {
70
+ throw new Error("AuthProvider.pollDeviceCode must be implemented by subclass");
71
+ }
72
+
73
+ /**
74
+ * Exchange a refresh token for a new access token.
75
+ * @param {{ refreshToken: string, scopes?: string[] }} params
76
+ * @returns {Promise<TokenResponse>}
77
+ */
78
+ async refresh(_params) {
79
+ throw new Error("AuthProvider.refresh must be implemented by subclass");
80
+ }
81
+
82
+ /**
83
+ * Revoke a token (access or refresh). Must not throw if already revoked.
84
+ * @param {{ token: string }} params
85
+ * @returns {Promise<void>}
86
+ */
87
+ async revoke(_params) {
88
+ throw new Error("AuthProvider.revoke must be implemented by subclass");
89
+ }
90
+ }
@@ -0,0 +1,116 @@
1
+ import { AuthProviderError } from "./ElixpoAuthProvider.js";
2
+
3
+ const DEFAULT_REFRESH_SKEW_MS = 60_000;
4
+ const storeLocks = new WeakMap();
5
+
6
+ function profileLocks(credentialStore) {
7
+ let locks = storeLocks.get(credentialStore);
8
+ if (!locks) {
9
+ locks = new Map();
10
+ storeLocks.set(credentialStore, locks);
11
+ }
12
+ return locks;
13
+ }
14
+
15
+ export class LoginRequiredError extends Error {
16
+ constructor(profileId) {
17
+ super(`Profile "${profileId}" needs to log in again.`);
18
+ this.name = "LoginRequiredError";
19
+ this.code = "login_required";
20
+ }
21
+ }
22
+
23
+ export class AuthenticatedClient {
24
+ constructor({
25
+ provider,
26
+ credentialStore,
27
+ profileId,
28
+ apiBaseUrl = "https://blogs.elixpo.com",
29
+ fetchImpl = globalThis.fetch,
30
+ refreshSkewMs = DEFAULT_REFRESH_SKEW_MS,
31
+ }) {
32
+ this.provider = provider;
33
+ this.credentialStore = credentialStore;
34
+ this.profileId = profileId;
35
+ this.apiBaseUrl = new URL(apiBaseUrl);
36
+ this.fetchImpl = fetchImpl;
37
+ this.refreshSkewMs = refreshSkewMs;
38
+ }
39
+
40
+ async _refresh(credentials, { force = false } = {}) {
41
+ const locks = profileLocks(this.credentialStore);
42
+ const existing = locks.get(this.profileId);
43
+ if (existing) return existing;
44
+
45
+ const operation = (async () => {
46
+ const latest = (await this.credentialStore.get(this.profileId)) || credentials;
47
+ if (!force && latest.expiresAt - Date.now() > this.refreshSkewMs) return latest;
48
+ try {
49
+ const token = await this.provider.refresh({
50
+ refreshToken: latest.refreshToken,
51
+ scopes: latest.scopes,
52
+ });
53
+ const rotated = {
54
+ accessToken: token.accessToken,
55
+ refreshToken: token.refreshToken,
56
+ expiresAt: Date.now() + token.expiresInSeconds * 1000,
57
+ scopes: token.scopes,
58
+ };
59
+ await this.credentialStore.set(this.profileId, rotated);
60
+ return rotated;
61
+ } catch (error) {
62
+ if (error instanceof AuthProviderError && error.requiresLogin) {
63
+ await this.credentialStore.delete(this.profileId);
64
+ throw new LoginRequiredError(this.profileId);
65
+ }
66
+ throw error;
67
+ }
68
+ })();
69
+
70
+ locks.set(this.profileId, operation);
71
+ try {
72
+ return await operation;
73
+ } finally {
74
+ if (locks.get(this.profileId) === operation) locks.delete(this.profileId);
75
+ }
76
+ }
77
+
78
+ async credentials({ forceRefresh = false } = {}) {
79
+ const stored = await this.credentialStore.get(this.profileId);
80
+ if (!stored) throw new LoginRequiredError(this.profileId);
81
+ if (forceRefresh || stored.expiresAt - Date.now() <= this.refreshSkewMs) {
82
+ return this._refresh(stored, { force: forceRefresh });
83
+ }
84
+ return stored;
85
+ }
86
+
87
+ async request(url, options = {}) {
88
+ const target = new URL(url, this.apiBaseUrl);
89
+ if (target.origin !== this.apiBaseUrl.origin || !target.pathname.startsWith("/api/v1/")) {
90
+ throw new Error("Authenticated CLI requests are restricted to the configured LixBlogs /api/v1 resource server.");
91
+ }
92
+ let credentials = await this.credentials();
93
+ const send = () => this.fetchImpl(target.toString(), {
94
+ ...options,
95
+ headers: { ...options.headers, authorization: `Bearer ${credentials.accessToken}` },
96
+ });
97
+ let response = await send();
98
+ if (response.status === 401) {
99
+ credentials = await this.credentials({ forceRefresh: true });
100
+ response = await send();
101
+ }
102
+ return response;
103
+ }
104
+
105
+ async requireScopes(required) {
106
+ const credentials = await this.credentials();
107
+ const missing = required.filter((scope) => !credentials.scopes.includes(scope));
108
+ if (missing.length) {
109
+ const error = new Error(`Login again with the required scope${missing.length > 1 ? 's' : ''}: ${missing.join(', ')}`);
110
+ error.name = 'InsufficientScopeError';
111
+ error.code = 'insufficient_scope';
112
+ error.missingScopes = missing;
113
+ throw error;
114
+ }
115
+ }
116
+ }
@@ -0,0 +1,281 @@
1
+ import { AuthProvider } from "./AuthProvider.js";
2
+
3
+ const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
4
+ const SUPPORTED_CONTRACT_MAJOR = 1;
5
+ const DEFAULT_TIMEOUT_MS = 15_000;
6
+
7
+ const SAFE_ERROR_MESSAGES = {
8
+ access_denied: "Login was denied.",
9
+ authorization_pending: "Login is awaiting approval.",
10
+ expired_token: "The device authorization expired. Start login again.",
11
+ invalid_client: "The LixBlogs CLI client is not registered for this environment.",
12
+ invalid_grant: "This session is no longer valid. Log in again.",
13
+ invalid_request: "Accounts rejected the authentication request.",
14
+ invalid_scope: "The requested LixBlogs permissions are not available for this client.",
15
+ server_error: "Accounts could not complete authentication. Try again later.",
16
+ slow_down: "Accounts requested slower polling.",
17
+ temporarily_unavailable: "Accounts is temporarily unavailable. Try again later.",
18
+ };
19
+
20
+ export class AuthProviderError extends Error {
21
+ constructor(code, { status = 0, requiresLogin = false } = {}) {
22
+ super(SAFE_ERROR_MESSAGES[code] || "Authentication failed.");
23
+ this.name = "AuthProviderError";
24
+ this.code = code || "authentication_failed";
25
+ this.status = status;
26
+ this.requiresLogin = requiresLogin;
27
+ }
28
+ }
29
+
30
+ export class CompatibilityError extends Error {
31
+ constructor(message) {
32
+ super(message);
33
+ this.name = "CompatibilityError";
34
+ this.code = "incompatible_accounts_contract";
35
+ }
36
+ }
37
+
38
+ function versionParts(value) {
39
+ return String(value || "0.0.0")
40
+ .split(".")
41
+ .slice(0, 3)
42
+ .map((part) => Number.parseInt(part, 10) || 0);
43
+ }
44
+
45
+ function versionAtLeast(current, minimum) {
46
+ const left = versionParts(current);
47
+ const right = versionParts(minimum);
48
+ for (let index = 0; index < 3; index += 1) {
49
+ if (left[index] !== right[index]) return left[index] > right[index];
50
+ }
51
+ return true;
52
+ }
53
+
54
+ function normalizeBaseUrl(value) {
55
+ const url = new URL(value);
56
+ url.pathname = url.pathname.replace(/\/$/, "");
57
+ url.search = "";
58
+ url.hash = "";
59
+ if (url.protocol !== "https:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1") {
60
+ throw new CompatibilityError("Accounts must use HTTPS outside local development.");
61
+ }
62
+ return url.toString().replace(/\/$/, "");
63
+ }
64
+
65
+ async function responseJson(response) {
66
+ try {
67
+ return await response.json();
68
+ } catch {
69
+ throw new AuthProviderError("server_error", { status: response.status });
70
+ }
71
+ }
72
+
73
+ function oauthError(payload, response) {
74
+ const code = typeof payload?.error === "string" ? payload.error : "server_error";
75
+ return new AuthProviderError(code, {
76
+ status: response.status,
77
+ requiresLogin: code === "invalid_grant" || code === "access_denied" || code === "expired_token",
78
+ });
79
+ }
80
+
81
+ function tokenResponse(payload, response) {
82
+ if (!response.ok) throw oauthError(payload, response);
83
+ if (
84
+ typeof payload?.access_token !== "string" ||
85
+ typeof payload?.refresh_token !== "string" ||
86
+ !Number.isFinite(Number(payload?.expires_in))
87
+ ) {
88
+ throw new AuthProviderError("server_error", { status: response.status });
89
+ }
90
+ return {
91
+ accessToken: payload.access_token,
92
+ refreshToken: payload.refresh_token,
93
+ expiresInSeconds: Number(payload.expires_in),
94
+ scopes: typeof payload.scope === "string" ? payload.scope.split(/\s+/).filter(Boolean) : [],
95
+ };
96
+ }
97
+
98
+ export class ElixpoAuthProvider extends AuthProvider {
99
+ constructor({
100
+ accountsBaseUrl = "https://accounts.elixpo.com",
101
+ clientId = "lixblogs-cli-prod",
102
+ audience = "blogs.elixpo.com",
103
+ cliVersion = "1.1.0",
104
+ fetchImpl = globalThis.fetch,
105
+ timeoutMs = DEFAULT_TIMEOUT_MS,
106
+ } = {}) {
107
+ super();
108
+ if (typeof fetchImpl !== "function") throw new TypeError("A fetch implementation is required.");
109
+ this.accountsBaseUrl = normalizeBaseUrl(accountsBaseUrl);
110
+ this.clientId = clientId;
111
+ this.audience = audience;
112
+ this.cliVersion = cliVersion;
113
+ this.fetchImpl = fetchImpl;
114
+ this.timeoutMs = timeoutMs;
115
+ this._metadata = null;
116
+ this._discoveryPromise = null;
117
+ }
118
+
119
+ get providerId() {
120
+ return "elixpo";
121
+ }
122
+
123
+ async _fetch(url, options = {}) {
124
+ const controller = new AbortController();
125
+ const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
126
+ try {
127
+ return await this.fetchImpl(url, {
128
+ ...options,
129
+ signal: options.signal || controller.signal,
130
+ headers: { accept: "application/json", ...options.headers },
131
+ });
132
+ } catch {
133
+ throw new AuthProviderError("temporarily_unavailable");
134
+ } finally {
135
+ clearTimeout(timeout);
136
+ }
137
+ }
138
+
139
+ async discover({ scopes = [] } = {}) {
140
+ if (!this._metadata) {
141
+ if (!this._discoveryPromise) this._discoveryPromise = this._loadDiscovery();
142
+ try {
143
+ this._metadata = await this._discoveryPromise;
144
+ } finally {
145
+ this._discoveryPromise = null;
146
+ }
147
+ }
148
+
149
+ const unsupported = scopes.filter((scope) => !this._metadata.scopes_supported.includes(scope));
150
+ if (unsupported.length) throw new AuthProviderError("invalid_scope");
151
+ return this._metadata;
152
+ }
153
+
154
+ async _loadDiscovery() {
155
+ const response = await this._fetch(`${this.accountsBaseUrl}/.well-known/oauth-authorization-server`);
156
+ const metadata = await responseJson(response);
157
+ if (!response.ok) throw new AuthProviderError("temporarily_unavailable", { status: response.status });
158
+
159
+ const contractMajor = versionParts(metadata.elixpo_contract_version)[0];
160
+ if (contractMajor !== SUPPORTED_CONTRACT_MAJOR) {
161
+ throw new CompatibilityError("Accounts uses an unsupported device-flow contract version.");
162
+ }
163
+ if (!versionAtLeast(this.cliVersion, metadata.elixpo_min_compatible_cli_version)) {
164
+ throw new CompatibilityError(
165
+ `This CLI is too old for Accounts. Upgrade to version ${metadata.elixpo_min_compatible_cli_version} or newer.`,
166
+ );
167
+ }
168
+ if (!Array.isArray(metadata.grant_types_supported) || !metadata.grant_types_supported.includes(DEVICE_GRANT)) {
169
+ throw new CompatibilityError("Accounts does not advertise OAuth device authorization.");
170
+ }
171
+
172
+ const requiredEndpoints = [
173
+ "device_authorization_endpoint",
174
+ "token_endpoint",
175
+ "revocation_endpoint",
176
+ ];
177
+ for (const field of requiredEndpoints) {
178
+ if (typeof metadata[field] !== "string") {
179
+ throw new CompatibilityError(`Accounts discovery is missing ${field}.`);
180
+ }
181
+ const endpoint = new URL(metadata[field]);
182
+ const accounts = new URL(this.accountsBaseUrl);
183
+ if (endpoint.origin !== accounts.origin) {
184
+ throw new CompatibilityError(`Accounts discovery returned an untrusted ${field}.`);
185
+ }
186
+ }
187
+
188
+ return {
189
+ ...metadata,
190
+ scopes_supported: Array.isArray(metadata.scopes_supported) ? metadata.scopes_supported : [],
191
+ };
192
+ }
193
+
194
+ async requestDeviceCode({ scopes }) {
195
+ const metadata = await this.discover({ scopes });
196
+ const response = await this._fetch(metadata.device_authorization_endpoint, {
197
+ method: "POST",
198
+ headers: { "content-type": "application/json" },
199
+ body: JSON.stringify({
200
+ client_id: this.clientId,
201
+ scope: scopes.join(" "),
202
+ audience: this.audience,
203
+ }),
204
+ });
205
+ const payload = await responseJson(response);
206
+ if (!response.ok) throw oauthError(payload, response);
207
+ if (
208
+ typeof payload.device_code !== "string" ||
209
+ typeof payload.user_code !== "string" ||
210
+ typeof payload.verification_uri !== "string"
211
+ ) {
212
+ throw new AuthProviderError("server_error", { status: response.status });
213
+ }
214
+ return {
215
+ deviceCode: payload.device_code,
216
+ userCode: payload.user_code,
217
+ verificationUri: payload.verification_uri,
218
+ verificationUriComplete: payload.verification_uri_complete || payload.verification_uri,
219
+ expiresInSeconds: Number(payload.expires_in) || 600,
220
+ pollIntervalSeconds: Number(payload.interval) || 5,
221
+ };
222
+ }
223
+
224
+ async pollDeviceCode({ deviceCode }) {
225
+ const metadata = await this.discover();
226
+ const body = new URLSearchParams({
227
+ grant_type: DEVICE_GRANT,
228
+ device_code: deviceCode,
229
+ client_id: this.clientId,
230
+ });
231
+ const response = await this._fetch(metadata.token_endpoint, {
232
+ method: "POST",
233
+ headers: { "content-type": "application/x-www-form-urlencoded" },
234
+ body,
235
+ });
236
+ const payload = await responseJson(response);
237
+ if (response.ok) return { status: "approved", token: tokenResponse(payload, response) };
238
+ if (payload?.error === "authorization_pending") return { status: "pending" };
239
+ if (payload?.error === "slow_down") {
240
+ const polling = metadata.elixpo_device_flow_polling || {};
241
+ const increase = Math.max(
242
+ 5,
243
+ Number(polling.slow_down_interval_seconds || 10) - Number(polling.interval_seconds || 5),
244
+ );
245
+ return { status: "slow_down", pollIntervalIncreaseSeconds: increase };
246
+ }
247
+ if (payload?.error === "access_denied") return { status: "denied" };
248
+ if (payload?.error === "expired_token") return { status: "expired" };
249
+ throw oauthError(payload, response);
250
+ }
251
+
252
+ async refresh({ refreshToken, scopes }) {
253
+ const metadata = await this.discover({ scopes: scopes || [] });
254
+ const body = new URLSearchParams({
255
+ grant_type: "refresh_token",
256
+ refresh_token: refreshToken,
257
+ client_id: this.clientId,
258
+ });
259
+ if (scopes?.length) body.set("scope", scopes.join(" "));
260
+ const response = await this._fetch(metadata.token_endpoint, {
261
+ method: "POST",
262
+ headers: { "content-type": "application/x-www-form-urlencoded" },
263
+ body,
264
+ });
265
+ const payload = await responseJson(response);
266
+ return tokenResponse(payload, response);
267
+ }
268
+
269
+ async revoke({ token }) {
270
+ const metadata = await this.discover();
271
+ const response = await this._fetch(metadata.revocation_endpoint, {
272
+ method: "POST",
273
+ headers: { "content-type": "application/x-www-form-urlencoded" },
274
+ body: new URLSearchParams({ token, client_id: this.clientId }),
275
+ });
276
+ if (!response.ok) {
277
+ const payload = await responseJson(response);
278
+ throw oauthError(payload, response);
279
+ }
280
+ }
281
+ }
@@ -0,0 +1,170 @@
1
+ import { AuthProvider } from "./AuthProvider.js";
2
+
3
+ /**
4
+ * MockAuthProvider — deterministic, in-memory device-flow simulation for
5
+ * development and tests. Never talks to a real server.
6
+ *
7
+ * Per maintainer direction: this exists so CLI/UI work isn't blocked while
8
+ * accounts.elixpo.com's device-flow support has no confirmed ETA. This must
9
+ * never be reachable in a production configuration — see
10
+ * assertNotProduction() in productionGate.js, which every provider
11
+ * constructor call should be routed through at the call site.
12
+ *
13
+ * States simulated (mirroring "Device login, refresh, logout, revocation,
14
+ * expiry, denial, and polling errors are tested" from #135's acceptance
15
+ * criteria):
16
+ * - approved (successful login)
17
+ * - pending (still polling)
18
+ * - denied
19
+ * - expired
20
+ * - invalid/unknown device code
21
+ * - slow_down (RFC 8628 §3.5 style rate-limit signal)
22
+ * - refresh success / refresh failure
23
+ * - revoke
24
+ *
25
+ * Scenario selection is deterministic and driven by the deviceCode's prefix,
26
+ * not randomness — so tests are reproducible. See SCENARIO_PREFIX below.
27
+ *
28
+ * --- Open questions from elixpo/blogs.elixpo#137, resolved by implementer ---
29
+ * - Mock states: slow_down added (see above), matching RFC 8628 rather than
30
+ * inventing a bespoke rate-limit shape, so the real ElixpoAuthProvider can
31
+ * follow the same contract later.
32
+ * - Polling/backoff: on slow_down, caller must increase its poll interval by
33
+ * SLOW_DOWN_INTERVAL_INCREASE_SECONDS before polling again. No other
34
+ * backoff logic in the mock itself — backoff is the CLI's responsibility,
35
+ * not the provider's.
36
+ * Flagged for the maintainer to override if a different behavior is wanted.
37
+ */
38
+
39
+ const SCENARIO_PREFIX = {
40
+ APPROVE_IMMEDIATELY: "mock-approve-",
41
+ PENDING_THEN_APPROVE: "mock-pending-then-approve-",
42
+ DENY: "mock-deny-",
43
+ EXPIRE: "mock-expire-",
44
+ SLOW_DOWN_THEN_APPROVE: "mock-slow-down-then-approve-",
45
+ };
46
+
47
+ // Mirrors RFC 8628 §3.5: on slow_down, the client must increase its polling
48
+ // interval by this many seconds. Real ElixpoAuthProvider should follow the
49
+ // same contract so CLI polling logic doesn't need a provider-specific branch.
50
+ const SLOW_DOWN_INTERVAL_INCREASE_SECONDS = 5;
51
+
52
+ let counter = 0;
53
+ function nextId(prefix) {
54
+ counter += 1;
55
+ return `${prefix}${counter}`;
56
+ }
57
+
58
+ export class MockAuthProvider extends AuthProvider {
59
+ constructor() {
60
+ super();
61
+ /** @type {Map<string, { scenario: string, pollCount: number, createdAt: number, scopes: string[] }>} */
62
+ this._devicesCodes = new Map();
63
+ /** @type {Set<string>} revoked tokens */
64
+ this._revoked = new Set();
65
+ /** @type {Set<string>} tokens that will fail on next refresh (for testing refresh failure) */
66
+ this._refreshWillFail = new Set();
67
+ }
68
+
69
+ get providerId() {
70
+ return "mock";
71
+ }
72
+
73
+ /**
74
+ * @param {{ scopes: string[], scenario?: keyof typeof SCENARIO_PREFIX }} params
75
+ * `scenario` lets tests/dev deterministically choose which path this
76
+ * device code will take. Defaults to APPROVE_IMMEDIATELY.
77
+ */
78
+ async requestDeviceCode({ scopes, scenario = "APPROVE_IMMEDIATELY" }) {
79
+ const prefix = SCENARIO_PREFIX[scenario] ?? SCENARIO_PREFIX.APPROVE_IMMEDIATELY;
80
+ const deviceCode = nextId(prefix);
81
+ const userCode = deviceCode.slice(-6).toUpperCase();
82
+
83
+ this._devicesCodes.set(deviceCode, {
84
+ scenario,
85
+ pollCount: 0,
86
+ createdAt: Date.now(),
87
+ scopes: [...scopes],
88
+ });
89
+
90
+ return {
91
+ deviceCode,
92
+ userCode,
93
+ verificationUri: "https://mock.lixblogs.local/device",
94
+ verificationUriComplete: `https://mock.lixblogs.local/device?user_code=${encodeURIComponent(userCode)}`,
95
+ expiresInSeconds: scenario === "EXPIRE" ? 1 : 600,
96
+ pollIntervalSeconds: 1,
97
+ };
98
+ }
99
+
100
+ async pollDeviceCode({ deviceCode }) {
101
+ const record = this._devicesCodes.get(deviceCode);
102
+
103
+ if (!record) {
104
+ // Unknown/invalid code — distinct from "expired": this code never existed.
105
+ return { status: "denied" };
106
+ }
107
+
108
+ if (record.scenario === "EXPIRE") {
109
+ return { status: "expired" };
110
+ }
111
+
112
+ if (record.scenario === "DENY") {
113
+ return { status: "denied" };
114
+ }
115
+
116
+ if (record.scenario === "PENDING_THEN_APPROVE") {
117
+ record.pollCount += 1;
118
+ if (record.pollCount < 2) {
119
+ return { status: "pending" };
120
+ }
121
+ // fall through to approve on the 2nd+ poll
122
+ }
123
+
124
+ if (record.scenario === "SLOW_DOWN_THEN_APPROVE") {
125
+ record.pollCount += 1;
126
+ if (record.pollCount < 2) {
127
+ return {
128
+ status: "slow_down",
129
+ pollIntervalIncreaseSeconds: SLOW_DOWN_INTERVAL_INCREASE_SECONDS,
130
+ };
131
+ }
132
+ // fall through to approve on the 2nd+ poll
133
+ }
134
+
135
+ return {
136
+ status: "approved",
137
+ token: {
138
+ accessToken: `mock-access-${deviceCode}`,
139
+ refreshToken: `mock-refresh-${deviceCode}`,
140
+ expiresInSeconds: 3600,
141
+ scopes: record.scopes,
142
+ },
143
+ };
144
+ }
145
+
146
+ async refresh({ refreshToken, scopes = [] }) {
147
+ if (this._revoked.has(refreshToken)) {
148
+ throw new Error("refresh token has been revoked");
149
+ }
150
+ if (this._refreshWillFail.has(refreshToken)) {
151
+ throw new Error("mock refresh failure (test-injected)");
152
+ }
153
+ return {
154
+ accessToken: `mock-access-refreshed-${refreshToken}`,
155
+ refreshToken,
156
+ expiresInSeconds: 3600,
157
+ scopes,
158
+ };
159
+ }
160
+
161
+ async revoke({ token }) {
162
+ // Must not throw if already revoked — revocation is idempotent.
163
+ this._revoked.add(token);
164
+ }
165
+
166
+ /** Test helper: force the next refresh() call for this token to fail. */
167
+ _simulateRefreshFailureFor(refreshToken) {
168
+ this._refreshWillFail.add(refreshToken);
169
+ }
170
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Production safety gate.
3
+ *
4
+ * Maintainer's explicit requirement (verbatim):
5
+ * "Production login must remain explicitly unavailable until the real
6
+ * issuer, polling, refresh, scope, and revocation contract is approved —
7
+ * no copied cookies or fallback credentials."
8
+ *
9
+ * This must fail loudly and immediately — not silently degrade, not warn
10
+ * and continue. Every call site that constructs an AuthProvider must route
11
+ * through assertProviderAllowed() first.
12
+ *
13
+ * Accounts now publishes the approved RFC 8628 contract, so production is
14
+ * enabled only for ElixpoAuthProvider. The deterministic mock remains usable
15
+ * in explicit development/test environments and can never cross this gate.
16
+ */
17
+
18
+ const APPROVED_PRODUCTION_PROVIDER_ID = "elixpo";
19
+
20
+ export class ProductionAuthGateError extends Error {
21
+ constructor(message) {
22
+ super(message);
23
+ this.name = "ProductionAuthGateError";
24
+ }
25
+ }
26
+
27
+ /**
28
+ * @param {{ providerId: string, environment: string }} params
29
+ * `environment` should come from explicit config, not inferred/guessed.
30
+ */
31
+ export function assertProviderAllowed({ providerId, environment }) {
32
+ const isProduction = environment === "production";
33
+
34
+ if (!isProduction) {
35
+ return; // any provider (including mock) is fine outside production
36
+ }
37
+
38
+ if (providerId !== APPROVED_PRODUCTION_PROVIDER_ID) {
39
+ throw new ProductionAuthGateError(
40
+ `Provider "${providerId}" is not approved for production. ` +
41
+ `Only "${APPROVED_PRODUCTION_PROVIDER_ID}" may be used in production.`
42
+ );
43
+ }
44
+ }