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
|
-
*
|
|
30
|
-
* 30-08 sources this from
|
|
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: `${
|
|
44
|
-
verification_uri_complete: `${
|
|
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
|