@mymehq/sdk 4.1.1 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -99,7 +99,7 @@ declare class MymeAuth {
99
99
  * Typed OAuth error surface. Mirrors the wire `error` codes from
100
100
  * RFC 6749 §5.2 and the rotation-replay extension.
101
101
  */
102
- type OAuthErrorCode = "invalid_request" | "invalid_client" | "invalid_grant" | "invalid_scope" | "invalid_token" | "unauthorized_client" | "unsupported_grant_type" | "unsupported_response_type" | "access_denied" | "insufficient_scope" | "token_reuse_detected" | "server_error" | "temporarily_unavailable";
102
+ type OAuthErrorCode = "invalid_request" | "invalid_client" | "invalid_grant" | "invalid_scope" | "invalid_token" | "unauthorized_client" | "unsupported_grant_type" | "unsupported_response_type" | "access_denied" | "insufficient_scope" | "token_reuse_detected" | "server_error" | "temporarily_unavailable" | "authorization_pending" | "slow_down" | "expired_token";
103
103
  declare class OAuthError extends Error {
104
104
  readonly code: OAuthErrorCode;
105
105
  readonly status: number;
@@ -126,4 +126,57 @@ declare function computeCodeChallenge(verifier: string): Promise<string>;
126
126
  /** Generate a random opaque state value for CSRF protection on the redirect. */
127
127
  declare function generateState(byteLength?: number): string;
128
128
 
129
- export { InMemoryTokenStorage, LocalStorageTokenStorage, MymeAuth, type MymeAuthConfig, OAuthError, type OAuthErrorCode, type TokenProvider, type TokenStorage, computeCodeChallenge, defaultTokenStorage, generateCodeVerifier, generateState };
129
+ /**
130
+ * Device Authorization Grant flow (RFC 8628) — for headless / CLI / native
131
+ * apps that can't host a browser callback.
132
+ *
133
+ * const handle = await startDeviceFlow({ issuer, clientId, scopes });
134
+ * console.log(`Visit ${handle.verification_uri} and enter ${handle.user_code}`);
135
+ * const provider = await handle.pollForToken();
136
+ *
137
+ * `pollForToken()` blocks until the user approves on the server (returning
138
+ * a `TokenProvider`), the user denies (throws OAuthError "access_denied"),
139
+ * or the device_code expires (throws OAuthError "expired_token"). Adheres
140
+ * to RFC 8628 polling rules: respects `interval`, doubles on `slow_down`.
141
+ */
142
+
143
+ interface StartDeviceFlowConfig {
144
+ /** Myme server URL — protocol + host (and port). */
145
+ issuer: string;
146
+ /** OAuth client id, registered via POST /auth/clients. */
147
+ clientId: string;
148
+ /** Scopes to request (e.g. ["core.note:read"]). */
149
+ scopes: string[];
150
+ /** Token storage for persisting the resulting access/refresh tokens.
151
+ * Defaults to localStorage in browser, in-memory in Node. CLI consumers
152
+ * typically pass a filesystem-backed implementation. */
153
+ storage?: TokenStorage;
154
+ /** Override for the global fetch (testing). */
155
+ fetch?: typeof globalThis.fetch;
156
+ }
157
+ interface DeviceFlowHandle {
158
+ /** Short, human-readable code the user types on the verification page
159
+ * (XXXX-XXXX shape). */
160
+ user_code: string;
161
+ /** URL the user should visit. */
162
+ verification_uri: string;
163
+ /** Same URL with `?user_code=…` appended; some clients can render this
164
+ * as a deep link / QR code so the user doesn't have to type. */
165
+ verification_uri_complete: string;
166
+ /** Seconds the device_code remains valid. */
167
+ expires_in: number;
168
+ /** Minimum seconds between polls. */
169
+ interval: number;
170
+ /** Block until the user approves on the server. Resolves with a
171
+ * TokenProvider that can be passed straight into `MymeClient`.
172
+ * Rejects with OAuthError on denial / expiry / fatal error. */
173
+ pollForToken(options?: {
174
+ signal?: AbortSignal;
175
+ }): Promise<TokenProvider>;
176
+ }
177
+ /** Initiate a Device Authorization Grant flow. The returned handle
178
+ * carries the user-facing code + URL plus a `pollForToken()` that
179
+ * blocks until approval. */
180
+ declare function startDeviceFlow(config: StartDeviceFlowConfig): Promise<DeviceFlowHandle>;
181
+
182
+ export { type DeviceFlowHandle, InMemoryTokenStorage, LocalStorageTokenStorage, MymeAuth, type MymeAuthConfig, OAuthError, type OAuthErrorCode, type StartDeviceFlowConfig, type TokenProvider, type TokenStorage, computeCodeChallenge, defaultTokenStorage, generateCodeVerifier, generateState, startDeviceFlow };
@@ -319,6 +319,127 @@ var MymeAuth = class {
319
319
  await this.storage.delete(this.pendingKey);
320
320
  }
321
321
  };
322
+
323
+ // src/auth/device-flow.ts
324
+ var DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
325
+ var DEFAULT_POLL_INTERVAL_MS = 5e3;
326
+ var SLOW_DOWN_BUMP_MS = 5e3;
327
+ async function startDeviceFlow(config) {
328
+ const issuer = config.issuer.replace(/\/+$/, "");
329
+ const fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
330
+ const storage = config.storage ?? defaultTokenStorage();
331
+ const initiateRes = await fetchImpl(`${issuer}/auth/device`, {
332
+ method: "POST",
333
+ headers: { "content-type": "application/json" },
334
+ body: JSON.stringify({
335
+ client_id: config.clientId,
336
+ scope: config.scopes.join(" ")
337
+ })
338
+ });
339
+ if (!initiateRes.ok) {
340
+ const body = await safeJson(initiateRes);
341
+ const errCode = extractErrorCode(body);
342
+ throw new OAuthError(
343
+ errCode,
344
+ `device_authorization initiate failed: ${String(initiateRes.status)}`
345
+ );
346
+ }
347
+ const initiated = await initiateRes.json();
348
+ let pollIntervalMs = typeof initiated.interval === "number" ? initiated.interval * 1e3 : DEFAULT_POLL_INTERVAL_MS;
349
+ const handle = {
350
+ user_code: initiated.user_code,
351
+ verification_uri: initiated.verification_uri,
352
+ verification_uri_complete: initiated.verification_uri_complete,
353
+ expires_in: initiated.expires_in,
354
+ interval: initiated.interval,
355
+ async pollForToken(options) {
356
+ const deadline = Date.now() + initiated.expires_in * 1e3;
357
+ while (Date.now() < deadline) {
358
+ if (options?.signal?.aborted) {
359
+ throw new OAuthError(
360
+ "invalid_request",
361
+ "Device flow aborted by caller"
362
+ );
363
+ }
364
+ await sleep(pollIntervalMs, options?.signal);
365
+ const res = await fetchImpl(`${issuer}/auth/device/token`, {
366
+ method: "POST",
367
+ headers: { "content-type": "application/x-www-form-urlencoded" },
368
+ body: new URLSearchParams({
369
+ grant_type: DEVICE_GRANT_TYPE,
370
+ device_code: initiated.device_code,
371
+ client_id: config.clientId
372
+ }).toString()
373
+ });
374
+ if (res.ok) {
375
+ const tokens = await res.json();
376
+ const storageKey = `myme.auth.tokens:${new URL(issuer).origin}:${config.clientId}`;
377
+ const provider = new StoredTokenProvider({
378
+ issuer,
379
+ clientId: config.clientId,
380
+ storage,
381
+ storageKey,
382
+ fetch: fetchImpl
383
+ });
384
+ await provider.persist({
385
+ access_token: tokens.access_token,
386
+ refresh_token: tokens.refresh_token,
387
+ access_expires_at: Date.now() + tokens.expires_in * 1e3,
388
+ scope: tokens.scope
389
+ });
390
+ return provider;
391
+ }
392
+ const body = await safeJson(res);
393
+ const code = body.error ?? "invalid_grant";
394
+ if (code === "authorization_pending") continue;
395
+ if (code === "slow_down") {
396
+ pollIntervalMs += SLOW_DOWN_BUMP_MS;
397
+ continue;
398
+ }
399
+ throw new OAuthError(
400
+ code,
401
+ body.error_description ?? "Device flow failed"
402
+ );
403
+ }
404
+ throw new OAuthError("expired_token", "Device flow expired");
405
+ }
406
+ };
407
+ return handle;
408
+ }
409
+ async function safeJson(res) {
410
+ try {
411
+ return await res.json();
412
+ } catch {
413
+ return {};
414
+ }
415
+ }
416
+ function extractErrorCode(body) {
417
+ const err = body.error;
418
+ if (typeof err === "string") return err;
419
+ if (typeof err === "object" && err !== null) {
420
+ const code = err.code;
421
+ if (typeof code === "string") return code;
422
+ }
423
+ return "invalid_request";
424
+ }
425
+ function sleep(ms, signal) {
426
+ return new Promise((resolve, reject) => {
427
+ if (signal?.aborted) {
428
+ reject(new Error("aborted"));
429
+ return;
430
+ }
431
+ const t = setTimeout(() => {
432
+ signal?.removeEventListener("abort", onAbort);
433
+ resolve();
434
+ }, ms);
435
+ const onAbort = () => {
436
+ clearTimeout(t);
437
+ signal?.removeEventListener("abort", onAbort);
438
+ reject(new Error("aborted"));
439
+ };
440
+ signal?.addEventListener("abort", onAbort);
441
+ });
442
+ }
322
443
  export {
323
444
  InMemoryTokenStorage,
324
445
  LocalStorageTokenStorage,
@@ -327,5 +448,6 @@ export {
327
448
  computeCodeChallenge,
328
449
  defaultTokenStorage,
329
450
  generateCodeVerifier,
330
- generateState
451
+ generateState,
452
+ startDeviceFlow
331
453
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mymehq/sdk",
3
- "version": "4.1.1",
3
+ "version": "4.2.0",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org",
@@ -20,7 +20,7 @@
20
20
  "dist"
21
21
  ],
22
22
  "dependencies": {
23
- "@mymehq/shared": "4.1.1"
23
+ "@mymehq/shared": "4.2.0"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/node": "^22.0.0",