wood-fired-tasks 2.0.3 → 2.0.5

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,43 @@ vulnerabilities, supply-chain pinning) are always called out under `Security`.
13
13
 
14
14
  _No changes yet._
15
15
 
16
+ ## [v2.0.5] - 2026-06-08
17
+
18
+ ### Changed
19
+ - **`tasks setup` → Remote now tells you the truth about what a server needs for
20
+ browser login.** When the server reports OIDC ready but you entered a
21
+ **plain-http, non-localhost URL**, browser/device login via Google SSO can
22
+ never complete — identity providers reject non-`https` OAuth redirect URIs
23
+ except for `localhost`. Previously setup would launch the device flow anyway
24
+ and the verification page dead-ended at the IdP callback ("unable to connect").
25
+ Setup now detects this up front and explains it: it tells you to either re-run
26
+ with an **https** URL (front the server with a TLS reverse proxy / real domain
27
+ so Google SSO completes) or **paste a personal access token**, and prints the
28
+ exact on-host command to mint one (`tasks db mint-token --user <email-or-id>`),
29
+ then drops straight into manual-PAT entry. `https` and `http://localhost`
30
+ servers are unaffected and complete browser login as before. New exported
31
+ helper `canUseBrowserSso()`.
32
+
33
+ ## [v2.0.4] - 2026-06-08
34
+
35
+ ### Fixed
36
+ - **Device-flow `verification_uri` now points at the address the client actually
37
+ connected to, not `localhost`.** `POST /auth/device/code` built the URL the
38
+ user opens in a browser from a STATIC configured origin (`OIDC_REDIRECT_URI`'s
39
+ origin), which is typically `http://localhost:3000`. A CLI that reached the
40
+ server over the LAN (e.g. `http://192.168.x.x:3000`) was told to open a
41
+ `localhost` URL pointing at its OWN machine — a dead end. The origin is now
42
+ derived per-request from the `Host` header (honoring `X-Forwarded-Host` /
43
+ `X-Forwarded-Proto` from a trusted reverse proxy), falling back to the
44
+ configured origin only when no `Host` is present. This is not a
45
+ host-header-injection vector: the `verification_uri` is returned only to the
46
+ same client that made the request. (Note: a Google-backed server whose OAuth
47
+ callback is `http://localhost` still can't complete the *browser login* leg
48
+ for a remote client — Google forbids non-`localhost` `http` redirect URIs — so
49
+ remote clients should use `tasks setup --remote <url> --token <pat>` or an
50
+ HTTPS domain. This fix makes the verification URL correct for properly
51
+ routable servers.)
52
+
16
53
  ## [v2.0.3] - 2026-06-08
17
54
 
18
55
  ### 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
  });
@@ -334,6 +334,23 @@ export declare function probeOidcState(baseUrl: string): Promise<OidcProbeResult
334
334
  * - probe failure → manual-PAT (connectivity escape hatch).
335
335
  */
336
336
  export declare function selectRemoteOnboardingMethod(probe: OidcProbeResult): RemoteOnboardingMethod;
337
+ /**
338
+ * Whether the browser/device login (Google SSO) can actually COMPLETE against
339
+ * `baseUrl` (#835).
340
+ *
341
+ * The whole OIDC dance — the verification page AND the IdP's OAuth callback —
342
+ * happens at the server's origin, and identity providers (Google especially)
343
+ * reject non-`https` OAuth redirect URIs *except* for `localhost`/`127.0.0.1`.
344
+ * So a server reached over plain `http` at a non-localhost address can report
345
+ * `oidc: 'ready'` yet still be unable to finish browser login: the user's
346
+ * browser gets bounced to an `http://…/auth/callback` the IdP won't honor. We
347
+ * detect that up front so the interview can tell the user the truth (need https,
348
+ * or use a PAT) instead of opening a URL that dead-ends.
349
+ *
350
+ * Returns true for any `https` URL and for `http://localhost` / `127.0.0.1` /
351
+ * `[::1]`; false for plain-http non-loopback hosts and unparseable input.
352
+ */
353
+ export declare function canUseBrowserSso(baseUrl: string): boolean;
337
354
  /**
338
355
  * The minimal identity envelope `GET /api/v1/me` returns (task #809). Mirrors
339
356
  * the fields {@link writeCredentials} needs so a manually-pasted PAT lands in
@@ -604,6 +604,37 @@ export function selectRemoteOnboardingMethod(probe) {
604
604
  return 'manual-pat';
605
605
  return probe.oidc === 'ready' ? 'device-flow' : 'manual-pat';
606
606
  }
607
+ /**
608
+ * Whether the browser/device login (Google SSO) can actually COMPLETE against
609
+ * `baseUrl` (#835).
610
+ *
611
+ * The whole OIDC dance — the verification page AND the IdP's OAuth callback —
612
+ * happens at the server's origin, and identity providers (Google especially)
613
+ * reject non-`https` OAuth redirect URIs *except* for `localhost`/`127.0.0.1`.
614
+ * So a server reached over plain `http` at a non-localhost address can report
615
+ * `oidc: 'ready'` yet still be unable to finish browser login: the user's
616
+ * browser gets bounced to an `http://…/auth/callback` the IdP won't honor. We
617
+ * detect that up front so the interview can tell the user the truth (need https,
618
+ * or use a PAT) instead of opening a URL that dead-ends.
619
+ *
620
+ * Returns true for any `https` URL and for `http://localhost` / `127.0.0.1` /
621
+ * `[::1]`; false for plain-http non-loopback hosts and unparseable input.
622
+ */
623
+ export function canUseBrowserSso(baseUrl) {
624
+ let url;
625
+ try {
626
+ url = new URL(baseUrl);
627
+ }
628
+ catch {
629
+ return false;
630
+ }
631
+ if (url.protocol === 'https:')
632
+ return true;
633
+ if (url.protocol !== 'http:')
634
+ return false;
635
+ const host = url.hostname.toLowerCase();
636
+ return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
637
+ }
607
638
  /**
608
639
  * Default manual-PAT persistence (task #809).
609
640
  *
@@ -811,6 +842,28 @@ export async function runRemoteOnboarding(options = {}) {
811
842
  // 3. Branch on the selected method.
812
843
  let method = selectRemoteOnboardingMethod(probeResult);
813
844
  const oidc = probeResult.ok ? probeResult.oidc : null;
845
+ // #835: even when the server reports OIDC ready, browser login can only
846
+ // COMPLETE over https (or localhost) — Google rejects non-https OAuth
847
+ // redirect URIs everywhere else. Catch a plain-http non-localhost URL here
848
+ // and tell the user plainly, then route to manual-PAT entry (with on-host
849
+ // mint instructions) rather than opening a verification URL that dead-ends at
850
+ // the IdP callback.
851
+ if (method === 'device-flow' && !canUseBrowserSso(baseUrl)) {
852
+ log('');
853
+ log(`"${baseUrl}" is plain http at a non-localhost address.`);
854
+ log('Browser login via Google SSO requires an https URL — identity providers');
855
+ log('reject non-https OAuth redirect URIs except for localhost — so the device');
856
+ log('flow cannot complete against this server. To finish setup, either:');
857
+ log(' • re-run with an https URL for this server (e.g. front it with a TLS');
858
+ log(' reverse proxy / real domain so Google SSO completes), or');
859
+ log(' • paste a personal access token now.');
860
+ log('');
861
+ log('To mint a PAT, run this ON THE SERVER HOST:');
862
+ log(' tasks db mint-token --user <your-email-or-user-id>');
863
+ log('(or create one from your account page once logged in via the browser).');
864
+ log('');
865
+ method = 'manual-pat';
866
+ }
814
867
  if (method === 'device-flow') {
815
868
  // Self-provision a PAT via the OIDC device flow (#806). runDeviceLogin owns
816
869
  // the entire RFC 8628 exchange AND persists the minted PAT via the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wood-fired-tasks",
3
- "version": "2.0.3",
3
+ "version": "2.0.5",
4
4
  "description": "Network-wide task tracking system for Wood Fired Games",
5
5
  "keywords": [
6
6
  "task-tracker",