wood-fired-tasks 2.0.1 → 2.0.3

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 CHANGED
@@ -13,6 +13,47 @@ vulnerabilities, supply-chain pinning) are always called out under `Security`.
13
13
 
14
14
  _No changes yet._
15
15
 
16
+ ## [v2.0.3] - 2026-06-08
17
+
18
+ ### Fixed
19
+ - **`tasks setup` device-flow login no longer fails with `invalid_client` on
20
+ servers backed by a real IdP.** The RFC 8628 device-flow `client_id` was
21
+ validated against `OIDC_CLIENT_ID` — the *IdP's* OAuth client id used for the
22
+ browser SSO leg — while the CLI sends a logical `'wft-cli'`. On any server
23
+ using e.g. Google, those never matched, so `POST /auth/device/code` returned
24
+ `400 invalid_client` and setup crashed. The device-flow client id is now a
25
+ **separate** setting, `OIDC_DEVICE_CLIENT_ID` (defaults to `'wft-cli'` on both
26
+ server and CLI), decoupled from `OIDC_CLIENT_ID` and not part of the
27
+ all-or-nothing OIDC group — so the stock CLI authenticates against a stock
28
+ server with no configuration. Operators who customize it set the same value
29
+ on both sides.
30
+ - **Remote onboarding degrades gracefully when the device flow can't start.**
31
+ A failed/throwing `POST /auth/device/code` (e.g. `invalid_client`, or a
32
+ network error) previously aborted `tasks setup` with a raw stack-trace-style
33
+ error. It now logs the reason and falls back to manual personal-access-token
34
+ entry, so onboarding can still complete.
35
+
36
+ ### Changed
37
+ - New optional env var **`OIDC_DEVICE_CLIENT_ID`** (default `'wft-cli'`),
38
+ documented in `docs/SETUP.md`. No action required for existing deployments
39
+ using the default CLI.
40
+
41
+ ## [v2.0.2] - 2026-06-08
42
+
43
+ ### Fixed
44
+ - **`tasks setup` remote onboarding now reaches the automated device-flow login
45
+ instead of always falling back to manual PAT entry.** The onboarding probe
46
+ read the server's OIDC state from `GET /health/detailed`, but v2.0's identity
47
+ cutover put that endpoint behind Bearer-PAT auth — so an unauthenticated probe
48
+ (the only kind possible *before* you have a token) always got `401`, the OIDC
49
+ state was "undeterminable", and setup fell back to asking you to paste a
50
+ personal access token. The probe now targets the **public** `GET /auth/login`
51
+ endpoint (`redirect: 'manual'`): a `3xx` redirect to the IdP means browser
52
+ login is available (`ready`), a `501` means OIDC is disabled (`manual-pat`).
53
+ Verified against a live OIDC-ready server: the probe now resolves `ready`
54
+ where it previously 401'd, so the device-flow browser login launches as
55
+ intended.
56
+
16
57
  ## [v2.0.1] - 2026-06-08
17
58
 
18
59
  ### Fixed
@@ -362,6 +362,11 @@ export async function createServer(options) {
362
362
  }, 'device-flow origin resolved to localhost — CLI verification_uri will be unroutable for remote clients');
363
363
  }
364
364
  const clientId = config.OIDC_CLIENT_ID;
365
+ // #833: the device-flow `client_id` is a SEPARATE logical identifier the
366
+ // CLI sends (default `'wft-cli'`), NOT the IdP's OAuth client id. Using
367
+ // OIDC_CLIENT_ID here rejected the stock CLI with `invalid_client` on any
368
+ // real-IdP server. Always defaulted, so device flow works out of the box.
369
+ const deviceClientId = config.OIDC_DEVICE_CLIENT_ID;
365
370
  await server.register(authRoutes, {
366
371
  prefix: '/auth',
367
372
  oidcConfig: app.oidcConfig,
@@ -413,9 +418,9 @@ export async function createServer(options) {
413
418
  await scope.register(authPlugin);
414
419
  await scope.register(deviceCodeRoute, {
415
420
  origin,
416
- expectedClientId: clientId,
421
+ expectedClientId: deviceClientId,
417
422
  });
418
- await scope.register(deviceTokenRoute, { expectedClientId: clientId });
423
+ await scope.register(deviceTokenRoute, { expectedClientId: deviceClientId });
419
424
  await scope.register(deviceHtmlRoute, { origin });
420
425
  });
421
426
  }
@@ -274,9 +274,11 @@ export declare function writeRemoteMcpEntryOnly(options?: RunSetupOptions): RunS
274
274
  */
275
275
  export type SetupMode = 'local' | 'service' | 'remote';
276
276
  /**
277
- * Coarse OIDC subsystem state reported by `GET /health/detailed` (task #357).
278
- * Mirrors the server's `oidc.state` enum so the remote-setup branch selector
279
- * (#807) can route deterministically.
277
+ * Coarse OIDC subsystem state used by the remote-setup branch selector (#807)
278
+ * to route deterministically. Derived by {@link probeOidcState} from the public
279
+ * `/auth/login` endpoint (`ready` = redirects to IdP, `disabled` = 501 stub).
280
+ * `degraded` is retained for back-compat but is no longer emitted by the probe
281
+ * (an unhealthy OIDC surfaces as an inconclusive status → manual-PAT fallback).
280
282
  */
281
283
  export type OidcState = 'ready' | 'disabled' | 'degraded';
282
284
  /**
@@ -287,10 +289,12 @@ export type OidcState = 'ready' | 'disabled' | 'degraded';
287
289
  */
288
290
  export type RemoteOnboardingMethod = 'device-flow' | 'manual-pat';
289
291
  /**
290
- * Result of probing `GET /health/detailed` for the OIDC state.
291
- * - `{ ok: true, oidc }` — the probe returned 2xx and an `oidc.state`.
292
- * - `{ ok: false, reason }` — network error / non-2xx / unparseable body.
293
- * The `reason` is surfaced to the user before falling back to manual PAT.
292
+ * Result of probing the server for whether browser/device-flow login is
293
+ * available.
294
+ * - `{ ok: true, oidc }` — the probe got a conclusive answer (`ready` =
295
+ * device-flow available, `disabled` = no OIDC manual PAT).
296
+ * - `{ ok: false, reason }` — network error / inconclusive status. The
297
+ * `reason` is surfaced to the user before falling back to manual PAT.
294
298
  */
295
299
  export type OidcProbeResult = {
296
300
  ok: true;
@@ -302,13 +306,22 @@ export type OidcProbeResult = {
302
306
  /** Injectable probe signature so tests can drive each branch without a server. */
303
307
  export type OidcProbe = (baseUrl: string) => Promise<OidcProbeResult>;
304
308
  /**
305
- * Default OIDC probe: `GET <baseUrl>/health/detailed` and read `oidc.state`.
309
+ * Default OIDC probe: `GET <baseUrl>/auth/login` (the browser-login entry
310
+ * point) and read OIDC availability from the HTTP status.
306
311
  *
307
- * `/health/detailed` is auth-protected on the server (Bearer PAT), but the only
308
- * field we need — `oidc.state` is returned in the JSON body regardless of the
309
- * auth-derived status code as long as the route renders. We treat ANY non-2xx
310
- * or unparseable/missing `oidc.state` as a probe failure and fall back to the
311
- * manual-PAT escape hatch (the route may not even exist on an older server).
312
+ * Why NOT `/health/detailed` (the original #807 target): v2.0's identity
313
+ * cutover put `/health/detailed` behind Bearer-PAT auth, so an unauthenticated
314
+ * probe which is the ONLY kind possible during onboarding, before any PAT
315
+ * exists gets a hard `401 {"error":"UNAUTHORIZED"}` with NO `oidc.state` in
316
+ * the body. That made this probe always fail and `setup --remote` always fall
317
+ * back to manual-PAT entry, defeating the automated device-flow login
318
+ * (wood-fired-tasks #831).
319
+ *
320
+ * `/auth/login` is PUBLIC (`skipAuth: true`) and an unambiguous signal:
321
+ * - OIDC ready → `302` redirect to the IdP authorize URL.
322
+ * - OIDC disabled → the disabled-stub returns `501`.
323
+ * We do NOT follow the redirect (`redirect: 'manual'`) — only its status
324
+ * matters. Any other/unreachable status is inconclusive → manual-PAT fallback.
312
325
  * Bounded by a 5s timeout so a half-open server can't hang `setup --remote`.
313
326
  */
314
327
  export declare function probeOidcState(baseUrl: string): Promise<OidcProbeResult>;
@@ -367,8 +380,8 @@ export declare function persistManualPat(baseUrl: string, token: string): Promis
367
380
  export interface RunSetupInteractiveOptions extends RunSetupOptions {
368
381
  /**
369
382
  * Injectable OIDC-state probe for the `--remote` path (#807). Defaults to
370
- * {@link probeOidcState} (a real `GET /health/detailed`). Tests stub this to
371
- * drive the ready / disabled / degraded / probe-failure branches.
383
+ * {@link probeOidcState} (a real `GET /auth/login`, public). Tests stub this
384
+ * to drive the ready / disabled / probe-failure branches.
372
385
  */
373
386
  oidcProbe?: OidcProbe;
374
387
  /**
@@ -546,40 +546,50 @@ export function writeRemoteMcpEntryOnly(options = {}) {
546
546
  };
547
547
  }
548
548
  /**
549
- * Default OIDC probe: `GET <baseUrl>/health/detailed` and read `oidc.state`.
549
+ * Default OIDC probe: `GET <baseUrl>/auth/login` (the browser-login entry
550
+ * point) and read OIDC availability from the HTTP status.
550
551
  *
551
- * `/health/detailed` is auth-protected on the server (Bearer PAT), but the only
552
- * field we need — `oidc.state` is returned in the JSON body regardless of the
553
- * auth-derived status code as long as the route renders. We treat ANY non-2xx
554
- * or unparseable/missing `oidc.state` as a probe failure and fall back to the
555
- * manual-PAT escape hatch (the route may not even exist on an older server).
552
+ * Why NOT `/health/detailed` (the original #807 target): v2.0's identity
553
+ * cutover put `/health/detailed` behind Bearer-PAT auth, so an unauthenticated
554
+ * probe which is the ONLY kind possible during onboarding, before any PAT
555
+ * exists gets a hard `401 {"error":"UNAUTHORIZED"}` with NO `oidc.state` in
556
+ * the body. That made this probe always fail and `setup --remote` always fall
557
+ * back to manual-PAT entry, defeating the automated device-flow login
558
+ * (wood-fired-tasks #831).
559
+ *
560
+ * `/auth/login` is PUBLIC (`skipAuth: true`) and an unambiguous signal:
561
+ * - OIDC ready → `302` redirect to the IdP authorize URL.
562
+ * - OIDC disabled → the disabled-stub returns `501`.
563
+ * We do NOT follow the redirect (`redirect: 'manual'`) — only its status
564
+ * matters. Any other/unreachable status is inconclusive → manual-PAT fallback.
556
565
  * Bounded by a 5s timeout so a half-open server can't hang `setup --remote`.
557
566
  */
558
567
  export async function probeOidcState(baseUrl) {
559
- const probeUrl = new URL('/health/detailed', baseUrl).toString();
568
+ const probeUrl = new URL('/auth/login', baseUrl).toString();
560
569
  let response;
561
570
  try {
562
- response = await fetch(probeUrl, { method: 'GET', signal: AbortSignal.timeout(5000) });
571
+ response = await fetch(probeUrl, {
572
+ method: 'GET',
573
+ redirect: 'manual',
574
+ signal: AbortSignal.timeout(5000),
575
+ });
563
576
  }
564
577
  catch (err) {
565
578
  const message = err instanceof Error ? err.message : String(err);
566
579
  return { ok: false, reason: `could not reach ${probeUrl}: ${message}` };
567
580
  }
568
- if (!response.ok) {
569
- return { ok: false, reason: `${probeUrl} returned HTTP ${response.status}` };
581
+ // 3xx → /auth/login is redirecting to the IdP → device-flow is available.
582
+ // (`redirect: 'manual'` surfaces the real 3xx status rather than following
583
+ // it; some runtimes report an opaque-redirect as status 0 with type
584
+ // 'opaqueredirect', which we also treat as a redirect.)
585
+ if ((response.status >= 300 && response.status < 400) || response.type === 'opaqueredirect') {
586
+ return { ok: true, oidc: 'ready' };
570
587
  }
571
- let body;
572
- try {
573
- body = await response.json();
574
- }
575
- catch {
576
- return { ok: false, reason: `${probeUrl} returned a non-JSON body` };
588
+ // 501 → the OIDC-disabled stub → no browser login; use a manual PAT.
589
+ if (response.status === 501) {
590
+ return { ok: true, oidc: 'disabled' };
577
591
  }
578
- const oidc = body?.oidc?.state;
579
- if (oidc === 'ready' || oidc === 'disabled' || oidc === 'degraded') {
580
- return { ok: true, oidc };
581
- }
582
- return { ok: false, reason: `${probeUrl} did not report an oidc.state` };
592
+ return { ok: false, reason: `${probeUrl} returned HTTP ${response.status}` };
583
593
  }
584
594
  /**
585
595
  * Map a probe outcome to the onboarding method (task #807).
@@ -799,32 +809,53 @@ export async function runRemoteOnboarding(options = {}) {
799
809
  log('This server supports browser login (OIDC ready); using the device flow.');
800
810
  }
801
811
  // 3. Branch on the selected method.
802
- const method = selectRemoteOnboardingMethod(probeResult);
812
+ let method = selectRemoteOnboardingMethod(probeResult);
803
813
  const oidc = probeResult.ok ? probeResult.oidc : null;
804
814
  if (method === 'device-flow') {
805
815
  // Self-provision a PAT via the OIDC device flow (#806). runDeviceLogin owns
806
816
  // the entire RFC 8628 exchange AND persists the minted PAT via the
807
817
  // credentials writer (writeCredentials) — there is NO host token-mint path
808
- // here. On failure it has already emitted its own error output; we just
809
- // propagate ok:false without writing anything.
810
- const result = await deviceLogin({
811
- baseUrl,
812
- clientId: process.env['OIDC_CLIENT_ID'] ?? 'wft-cli',
813
- hostname: os.hostname(),
814
- openBrowser: true,
815
- isJson: false,
816
- });
817
- if (!result.ok) {
818
- return { mode: 'remote', oidc, method, ok: false };
818
+ // here.
819
+ //
820
+ // client_id (#833): send the DEVICE-flow client id (`OIDC_DEVICE_CLIENT_ID`,
821
+ // default `'wft-cli'`), NOT the IdP's `OIDC_CLIENT_ID`. The server validates
822
+ // it against its own `OIDC_DEVICE_CLIENT_ID` (also defaulting to `'wft-cli'`),
823
+ // so the stock CLI matches a stock server out of the box.
824
+ let deviceOk = false;
825
+ try {
826
+ const result = await deviceLogin({
827
+ baseUrl,
828
+ clientId: process.env['OIDC_DEVICE_CLIENT_ID'] ?? 'wft-cli',
829
+ hostname: os.hostname(),
830
+ openBrowser: true,
831
+ isJson: false,
832
+ });
833
+ deviceOk = result.ok;
834
+ if (!deviceOk) {
835
+ log('Browser/device login did not complete; falling back to manual PAT entry.');
836
+ }
837
+ }
838
+ catch (err) {
839
+ // #833: device/code can hard-reject (e.g. a client_id mismatch →
840
+ // `invalid_client`) or the network can fail. Don't crash setup — degrade
841
+ // to manual personal-access-token entry so onboarding can still finish.
842
+ const message = err instanceof Error ? err.message : String(err);
843
+ log(`Browser/device login could not start (${message}).`);
844
+ log('Falling back to manual personal-access-token entry.');
845
+ }
846
+ if (deviceOk) {
847
+ // Device login succeeded and the PAT is already persisted in the
848
+ // credentials file. Now write the URL-only remote MCP entry (#810) into
849
+ // ~/.claude.json. The entry carries ONLY WFT_API_URL — the bridge resolves
850
+ // its bearer token at runtime from the credentials file the device flow
851
+ // just wrote, so NO token is ever embedded in claude.json and NO PAT is
852
+ // double-cached here.
853
+ const setup = writeRemoteMcpEntryOnly({ ...options, remote: baseUrl });
854
+ return { mode: 'remote', oidc, method, ok: true, setup };
819
855
  }
820
- // Device login succeeded and the PAT is already persisted in the
821
- // credentials file. Now write the URL-only remote MCP entry (#810) into
822
- // ~/.claude.json. The entry carries ONLY WFT_API_URL — the bridge resolves
823
- // its bearer token at runtime from the credentials file the device flow
824
- // just wrote, so NO token is ever embedded in claude.json and NO PAT is
825
- // double-cached here.
826
- const setup = writeRemoteMcpEntryOnly({ ...options, remote: baseUrl });
827
- return { mode: 'remote', oidc, method, ok: true, setup };
856
+ // Device flow unavailable/aborted fall through to the manual-PAT path
857
+ // below (records the method actually used so the result reflects reality).
858
+ method = 'manual-pat';
828
859
  }
829
860
  // method === 'manual-pat' (task #809): obtain a PAT, validate it, and persist
830
861
  // it through the SAME credentials writer the device flow uses
@@ -79,6 +79,7 @@ export declare const configSchema: z.ZodObject<{
79
79
  OIDC_CLIENT_ID: z.ZodOptional<z.ZodString>;
80
80
  OIDC_CLIENT_SECRET: z.ZodOptional<z.ZodString>;
81
81
  OIDC_REDIRECT_URI: z.ZodOptional<z.ZodString>;
82
+ OIDC_DEVICE_CLIENT_ID: z.ZodDefault<z.ZodString>;
82
83
  OIDC_POST_LOGOUT_REDIRECT_URI: z.ZodOptional<z.ZodString>;
83
84
  OIDC_SCOPES: z.ZodDefault<z.ZodString>;
84
85
  OIDC_DISCOVERY_MAX_ATTEMPTS: z.ZodPipe<z.ZodDefault<z.ZodString>, z.ZodTransform<number, string>>;
@@ -108,6 +108,16 @@ export const configSchema = z
108
108
  OIDC_CLIENT_ID: z.string().min(1).optional(),
109
109
  OIDC_CLIENT_SECRET: z.string().min(1).optional(),
110
110
  OIDC_REDIRECT_URI: z.string().url().optional(),
111
+ // RFC 8628 device-flow client_id (#833). DISTINCT from OIDC_CLIENT_ID: the
112
+ // latter is the IdP's OAuth client id used for the BROWSER SSO leg
113
+ // (`/auth/login` → Google), which is opaque to the CLI. The device-flow
114
+ // `client_id` is a logical identifier the CLI and server agree on out of
115
+ // band; the `tasks` CLI sends `'wft-cli'` by default. Conflating the two
116
+ // (the original Plan-30-08 wiring used OIDC_CLIENT_ID for both) made the
117
+ // device flow reject the CLI's default `client_id` with `invalid_client`
118
+ // on any server backed by a real IdP. Defaulted so it works with the
119
+ // stock CLI and is NOT part of the all-or-nothing OIDC group.
120
+ OIDC_DEVICE_CLIENT_ID: z.string().min(1).default('wft-cli'),
111
121
  // WR-03 fix — `post_logout_redirect_uri` for RP-initiated logout.
112
122
  // Optional: when absent, the wiring at src/api/server.ts derives a
113
123
  // default from OIDC_REDIRECT_URI's origin (+ `/auth/login`). Sourcing
package/docs/SETUP.md CHANGED
@@ -332,6 +332,14 @@ OIDC_REDIRECT_URI=http://localhost:3000/auth/callback
332
332
  # Optional — defaults to "openid email profile". The server requires at
333
333
  # minimum "openid email" to map the OIDC subject to a local user row.
334
334
  OIDC_SCOPES=openid email profile
335
+
336
+ # Optional — defaults to "wft-cli". The RFC 8628 device-flow client_id the
337
+ # `tasks` CLI sends during `tasks setup` → Remote. DISTINCT from OIDC_CLIENT_ID
338
+ # (the IdP's OAuth client id for the browser SSO leg): the device flow uses a
339
+ # logical client id the CLI and server agree on. Leave unset on both sides to
340
+ # use the default — the stock CLI then authenticates out of the box. Override
341
+ # only if you also set OIDC_DEVICE_CLIENT_ID to a matching value on the client.
342
+ OIDC_DEVICE_CLIENT_ID=wft-cli
335
343
  ```
336
344
 
337
345
  ### 3. Generate the session cookie secret
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wood-fired-tasks",
3
- "version": "2.0.1",
3
+ "version": "2.0.3",
4
4
  "description": "Network-wide task tracking system for Wood Fired Games",
5
5
  "keywords": [
6
6
  "task-tracker",