wood-fired-tasks 2.0.2 → 2.0.4

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,51 @@ vulnerabilities, supply-chain pinning) are always called out under `Security`.
13
13
 
14
14
  _No changes yet._
15
15
 
16
+ ## [v2.0.4] - 2026-06-08
17
+
18
+ ### Fixed
19
+ - **Device-flow `verification_uri` now points at the address the client actually
20
+ connected to, not `localhost`.** `POST /auth/device/code` built the URL the
21
+ user opens in a browser from a STATIC configured origin (`OIDC_REDIRECT_URI`'s
22
+ origin), which is typically `http://localhost:3000`. A CLI that reached the
23
+ server over the LAN (e.g. `http://192.168.x.x:3000`) was told to open a
24
+ `localhost` URL pointing at its OWN machine — a dead end. The origin is now
25
+ derived per-request from the `Host` header (honoring `X-Forwarded-Host` /
26
+ `X-Forwarded-Proto` from a trusted reverse proxy), falling back to the
27
+ configured origin only when no `Host` is present. This is not a
28
+ host-header-injection vector: the `verification_uri` is returned only to the
29
+ same client that made the request. (Note: a Google-backed server whose OAuth
30
+ callback is `http://localhost` still can't complete the *browser login* leg
31
+ for a remote client — Google forbids non-`localhost` `http` redirect URIs — so
32
+ remote clients should use `tasks setup --remote <url> --token <pat>` or an
33
+ HTTPS domain. This fix makes the verification URL correct for properly
34
+ routable servers.)
35
+
36
+ ## [v2.0.3] - 2026-06-08
37
+
38
+ ### Fixed
39
+ - **`tasks setup` device-flow login no longer fails with `invalid_client` on
40
+ servers backed by a real IdP.** The RFC 8628 device-flow `client_id` was
41
+ validated against `OIDC_CLIENT_ID` — the *IdP's* OAuth client id used for the
42
+ browser SSO leg — while the CLI sends a logical `'wft-cli'`. On any server
43
+ using e.g. Google, those never matched, so `POST /auth/device/code` returned
44
+ `400 invalid_client` and setup crashed. The device-flow client id is now a
45
+ **separate** setting, `OIDC_DEVICE_CLIENT_ID` (defaults to `'wft-cli'` on both
46
+ server and CLI), decoupled from `OIDC_CLIENT_ID` and not part of the
47
+ all-or-nothing OIDC group — so the stock CLI authenticates against a stock
48
+ server with no configuration. Operators who customize it set the same value
49
+ on both sides.
50
+ - **Remote onboarding degrades gracefully when the device flow can't start.**
51
+ A failed/throwing `POST /auth/device/code` (e.g. `invalid_client`, or a
52
+ network error) previously aborted `tasks setup` with a raw stack-trace-style
53
+ error. It now logs the reason and falls back to manual personal-access-token
54
+ entry, so onboarding can still complete.
55
+
56
+ ### Changed
57
+ - New optional env var **`OIDC_DEVICE_CLIENT_ID`** (default `'wft-cli'`),
58
+ documented in `docs/SETUP.md`. No action required for existing deployments
59
+ using the default CLI.
60
+
16
61
  ## [v2.0.2] - 2026-06-08
17
62
 
18
63
  ### Fixed
@@ -26,9 +26,15 @@
26
26
  import type { FastifyPluginAsync } from 'fastify';
27
27
  export interface DeviceCodeRouteOptions {
28
28
  /**
29
- * Server origin used to build the verification URIs the CLI prints. Plan
30
- * 30-08 sources this from `new URL(env.OIDC_REDIRECT_URI).origin`.
31
- * Example: `https://woodfiredbugs.local`.
29
+ * FALLBACK origin for the verification URIs the CLI prints, used only when
30
+ * the request carries no usable Host header. Plan 30-08 sources this from
31
+ * `new URL(env.OIDC_REDIRECT_URI).origin`. Example: `https://woodfiredbugs.local`.
32
+ *
33
+ * #834: the verification origin is now derived PER-REQUEST from the address
34
+ * the client actually connected to (see {@link resolveVerificationOrigin}),
35
+ * because this configured value is typically `http://localhost:3000` and is
36
+ * unroutable for any client that reached the server over the LAN / a real
37
+ * hostname. `origin` remains as the no-Host-header fallback.
32
38
  */
33
39
  origin: string;
34
40
  /**
@@ -37,5 +43,25 @@ export interface DeviceCodeRouteOptions {
37
43
  */
38
44
  expectedClientId: string;
39
45
  }
46
+ /**
47
+ * Resolve the origin (`scheme://host[:port]`) the CLIENT used to reach this
48
+ * server, for building the device-flow `verification_uri` the user opens in a
49
+ * browser (#834).
50
+ *
51
+ * Previously this was a STATIC configured origin (`OIDC_REDIRECT_URI`'s origin),
52
+ * which is `http://localhost:3000` on a typical server — so a CLI that connected
53
+ * over the LAN (e.g. `http://192.168.x.x:3000`) was told to open a localhost URL
54
+ * pointing at its OWN machine. We instead use the host the request arrived on,
55
+ * honoring `X-Forwarded-{Host,Proto}` from a trusted reverse proxy.
56
+ *
57
+ * Security: this is NOT a host-header-injection vector. The `verification_uri`
58
+ * is returned ONLY to the same client that sent the request, so a spoofed Host
59
+ * merely misdirects the spoofer. Falls back to `fallback` (the configured
60
+ * origin) when no Host header is present at all.
61
+ */
62
+ export declare function resolveVerificationOrigin(request: {
63
+ headers: Record<string, string | string[] | undefined>;
64
+ protocol?: string;
65
+ }, fallback: string): string;
40
66
  declare const deviceCodeRoute: FastifyPluginAsync<DeviceCodeRouteOptions>;
41
67
  export default deviceCodeRoute;
@@ -1,5 +1,38 @@
1
1
  import { z } from 'zod';
2
2
  import { createSession } from '../../../services/device-flow-store.js';
3
+ /** First value of a possibly comma-joined / array-valued HTTP header. */
4
+ function firstHeaderValue(v) {
5
+ const raw = Array.isArray(v) ? v[0] : v;
6
+ if (typeof raw !== 'string')
7
+ return undefined;
8
+ const first = raw.split(',')[0]?.trim();
9
+ return first && first.length > 0 ? first : undefined;
10
+ }
11
+ /**
12
+ * Resolve the origin (`scheme://host[:port]`) the CLIENT used to reach this
13
+ * server, for building the device-flow `verification_uri` the user opens in a
14
+ * browser (#834).
15
+ *
16
+ * Previously this was a STATIC configured origin (`OIDC_REDIRECT_URI`'s origin),
17
+ * which is `http://localhost:3000` on a typical server — so a CLI that connected
18
+ * over the LAN (e.g. `http://192.168.x.x:3000`) was told to open a localhost URL
19
+ * pointing at its OWN machine. We instead use the host the request arrived on,
20
+ * honoring `X-Forwarded-{Host,Proto}` from a trusted reverse proxy.
21
+ *
22
+ * Security: this is NOT a host-header-injection vector. The `verification_uri`
23
+ * is returned ONLY to the same client that sent the request, so a spoofed Host
24
+ * merely misdirects the spoofer. Falls back to `fallback` (the configured
25
+ * origin) when no Host header is present at all.
26
+ */
27
+ export function resolveVerificationOrigin(request, fallback) {
28
+ const host = firstHeaderValue(request.headers['x-forwarded-host']) ??
29
+ firstHeaderValue(request.headers['host']);
30
+ if (!host)
31
+ return fallback;
32
+ const scheme = firstHeaderValue(request.headers['x-forwarded-proto']) ??
33
+ (request.protocol && request.protocol.length > 0 ? request.protocol : 'http');
34
+ return `${scheme}://${host}`;
35
+ }
3
36
  /**
4
37
  * Body schema for POST /auth/device/code (JSON only — RFC 8628 lets servers
5
38
  * pick; the CLI always sends JSON). `scope` is accepted-and-ignored in v1.6
@@ -37,11 +70,15 @@ const deviceCodeRoute = async (fastify, opts) => {
37
70
  clientId: client_id,
38
71
  hostname: hostname ?? null,
39
72
  }, 'device flow started');
73
+ // #834: build the verification URL from the address the CLIENT connected to
74
+ // (request Host / X-Forwarded-*), not the static configured origin, so a
75
+ // remote/LAN client gets a URL it can actually open instead of localhost.
76
+ const origin = resolveVerificationOrigin(request, opts.origin);
40
77
  return reply.code(200).send({
41
78
  device_code: session.deviceCode,
42
79
  user_code: session.userCode,
43
- verification_uri: `${opts.origin}/auth/device`,
44
- verification_uri_complete: `${opts.origin}/auth/device?user_code=${session.userCode}`,
80
+ verification_uri: `${origin}/auth/device`,
81
+ verification_uri_complete: `${origin}/auth/device?user_code=${session.userCode}`,
45
82
  expires_in: 600,
46
83
  interval: 5,
47
84
  });
@@ -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
  }
@@ -809,32 +809,53 @@ export async function runRemoteOnboarding(options = {}) {
809
809
  log('This server supports browser login (OIDC ready); using the device flow.');
810
810
  }
811
811
  // 3. Branch on the selected method.
812
- const method = selectRemoteOnboardingMethod(probeResult);
812
+ let method = selectRemoteOnboardingMethod(probeResult);
813
813
  const oidc = probeResult.ok ? probeResult.oidc : null;
814
814
  if (method === 'device-flow') {
815
815
  // Self-provision a PAT via the OIDC device flow (#806). runDeviceLogin owns
816
816
  // the entire RFC 8628 exchange AND persists the minted PAT via the
817
817
  // credentials writer (writeCredentials) — there is NO host token-mint path
818
- // here. On failure it has already emitted its own error output; we just
819
- // propagate ok:false without writing anything.
820
- const result = await deviceLogin({
821
- baseUrl,
822
- clientId: process.env['OIDC_CLIENT_ID'] ?? 'wft-cli',
823
- hostname: os.hostname(),
824
- openBrowser: true,
825
- isJson: false,
826
- });
827
- if (!result.ok) {
828
- 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 };
829
855
  }
830
- // Device login succeeded and the PAT is already persisted in the
831
- // credentials file. Now write the URL-only remote MCP entry (#810) into
832
- // ~/.claude.json. The entry carries ONLY WFT_API_URL — the bridge resolves
833
- // its bearer token at runtime from the credentials file the device flow
834
- // just wrote, so NO token is ever embedded in claude.json and NO PAT is
835
- // double-cached here.
836
- const setup = writeRemoteMcpEntryOnly({ ...options, remote: baseUrl });
837
- 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';
838
859
  }
839
860
  // method === 'manual-pat' (task #809): obtain a PAT, validate it, and persist
840
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.2",
3
+ "version": "2.0.4",
4
4
  "description": "Network-wide task tracking system for Wood Fired Games",
5
5
  "keywords": [
6
6
  "task-tracker",