@dropsh/plugin-oauth2 0.7.1 → 0.7.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/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  OAuth 2.0 authentication provider for [dropsh](https://github.com/pen-drop/dropsh),
4
4
  the entity-agnostic CLI for Drupal 11 JSON:API.
5
5
 
6
- Adds an OAuth2 login option to `dropsh auth login`. Supports three grant types
6
+ Adds an OAuth2 login option to `dropsh auth login`. Supports four grant types
7
7
  against Drupal's `simple_oauth` module.
8
8
 
9
9
  ## Install
@@ -45,6 +45,39 @@ dropsh auth login --provider oauth2_authcode
45
45
  dropsh auth status
46
46
  ```
47
47
 
48
+ ### Device flow (remote machine, no local browser)
49
+
50
+ Use `oauth2_device_code` when dropsh runs where your browser is not — over SSH, in a container, on
51
+ a build host. There is no callback listener, no redirect URI and no client secret: dropsh prints a
52
+ URL and a short code, you confirm the login on any other device, and dropsh polls until it is done.
53
+
54
+ ```js
55
+ oauth2Plugin({
56
+ type: "oauth2_device_code",
57
+ client_id: "my-client",
58
+ device_authorization_url: "https://my-drupal.example.com/oauth/device_authorization",
59
+ token_url: "https://my-drupal.example.com/oauth/token",
60
+ // scope: "content",
61
+ }),
62
+ ```
63
+
64
+ ```bash
65
+ dropsh auth login --provider oauth2_device_code
66
+
67
+ Open https://my-drupal.example.com/oauth/device
68
+ and enter this code:
69
+
70
+ ABCD-EFGH
71
+
72
+ Waiting for confirmation ...
73
+ ```
74
+
75
+ dropsh never opens a browser for this flow, so the URL and the code stay on screen for use on
76
+ another device. Press Ctrl+C to cancel. The stored tokens behave like any other profile —
77
+ `dropsh auth status`, `dropsh auth logout` and the automatic refresh all work unchanged. If the
78
+ server issues no `refresh_token` for the device grant, an expired session asks for a full login
79
+ again instead of renewing.
80
+
48
81
  ## Named profiles
49
82
 
50
83
  Every `oauth2Plugin` is one auth **profile**, identified by its `id`. `id` defaults
@@ -69,12 +102,14 @@ for `auth use`, `--auth-profile`, and selection precedence.
69
102
  | `oauth2_authcode` | opens browser (PKCE, public client) | yes — via `refresh_token` |
70
103
  | `oauth2_password` | client secret + user password | yes — if `client_secret` in config |
71
104
  | `oauth2_client_credentials` | client secret | yes — if `client_secret` in config |
105
+ | `oauth2_device_code` | prints a URL + code, no browser | yes — via `refresh_token` |
72
106
 
73
107
  Config fields per grant (all accept the shared `id` and `default`):
74
108
 
75
109
  - `oauth2_authcode` — `client_id`, `token_url`, optional `scope`, `redirect_port`
76
110
  - `oauth2_password` — `client_id`, `token_url`, `username`, optional `scope`, `client_secret`
77
111
  - `oauth2_client_credentials` — `client_id`, `token_url`, optional `scope`, `client_secret`
112
+ - `oauth2_device_code` — `client_id`, `device_authorization_url`, `token_url`, optional `scope`
78
113
 
79
114
  `client_secret` and the user `password` may be **prompted at login** and are never
80
115
  stored on disk. For unattended runs, put `client_secret` in config so dropsh can
@@ -0,0 +1,15 @@
1
+ import type { AuthSession, HttpClient } from "dropsh/plugin";
2
+ export interface AcquireDeviceDeps {
3
+ clientId: string;
4
+ deviceAuthorizationUrl: string;
5
+ tokenUrl: string;
6
+ scope?: string;
7
+ http: HttpClient;
8
+ stdout: (s: string) => void;
9
+ now: () => number;
10
+ /** Injected in tests so no wall-clock time passes. */
11
+ _sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
12
+ /** Injected in tests to drive the cancellation path without a real SIGINT. */
13
+ signal?: AbortSignal;
14
+ }
15
+ export declare function acquireDeviceSession(deps: AcquireDeviceDeps): Promise<AuthSession>;
@@ -0,0 +1,165 @@
1
+ import { AuthError, HttpError } from "dropsh/plugin";
2
+ import { parseTokenResponse, readOAuthError } from "./token.js";
3
+ const DEFAULT_INTERVAL_SEC = 5;
4
+ const SLOW_DOWN_INCREMENT_SEC = 5;
5
+ const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
6
+ /** Resolves after `ms`, or rejects immediately once `signal` aborts. */
7
+ function sleep(ms, signal) {
8
+ return new Promise((resolve, reject) => {
9
+ if (signal.aborted)
10
+ return reject(new AuthError("Login cancelled."));
11
+ const timer = setTimeout(() => {
12
+ signal.removeEventListener("abort", onAbort);
13
+ resolve();
14
+ }, ms);
15
+ function onAbort() {
16
+ clearTimeout(timer);
17
+ reject(new AuthError("Login cancelled."));
18
+ }
19
+ signal.addEventListener("abort", onAbort, { once: true });
20
+ });
21
+ }
22
+ async function requestDeviceAuthorization(deps) {
23
+ const params = new URLSearchParams({ client_id: deps.clientId });
24
+ if (deps.scope)
25
+ params.set("scope", deps.scope);
26
+ let body;
27
+ try {
28
+ const res = await deps.http.send({
29
+ method: "POST",
30
+ url: deps.deviceAuthorizationUrl,
31
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
32
+ body: params.toString(),
33
+ });
34
+ body = JSON.parse(res.body);
35
+ }
36
+ catch (err) {
37
+ if (err instanceof SyntaxError)
38
+ throw new AuthError(`Device authorization response from ${deps.deviceAuthorizationUrl} is not valid JSON.`);
39
+ const code = err instanceof HttpError ? readOAuthError(err.body) : undefined;
40
+ throw new AuthError(`Device authorization request rejected (client_id "${deps.clientId}") at ` +
41
+ `${deps.deviceAuthorizationUrl}: ${code ?? "request_failed"}.`, { reason: code ?? "request_failed", client_id: deps.clientId });
42
+ }
43
+ const str = (k) => {
44
+ const v = body[k];
45
+ if (typeof v !== "string" || v.length === 0)
46
+ throw new AuthError(`Device authorization response from ${deps.deviceAuthorizationUrl} is missing "${k}".`);
47
+ return v;
48
+ };
49
+ if (typeof body.expires_in !== "number")
50
+ throw new AuthError(`Device authorization response from ${deps.deviceAuthorizationUrl} is missing "expires_in".`);
51
+ return {
52
+ device_code: str("device_code"),
53
+ user_code: str("user_code"),
54
+ verification_uri: str("verification_uri"),
55
+ expires_in: body.expires_in,
56
+ ...(typeof body.interval === "number" ? { interval: body.interval } : {}),
57
+ ...(typeof body.verification_uri_complete === "string"
58
+ ? { verification_uri_complete: body.verification_uri_complete }
59
+ : {}),
60
+ };
61
+ }
62
+ /**
63
+ * Print the activation instructions. Deliberately never opens a browser: the
64
+ * flow exists for machines whose local browser is on the wrong host, so URL and
65
+ * code must stay readable and usable on a second device.
66
+ */
67
+ function announce(auth, stdout) {
68
+ stdout(`Open ${auth.verification_uri}\nand enter this code:\n\n ${auth.user_code}\n`);
69
+ if (auth.verification_uri_complete)
70
+ stdout(`Or open this link directly:\n${auth.verification_uri_complete}\n`);
71
+ stdout("Waiting for confirmation ...");
72
+ }
73
+ function deviceErrorMessage(code) {
74
+ if (code === "access_denied")
75
+ return "The login was denied on the confirmation page.";
76
+ if (code === "expired_token")
77
+ return "The device code expired before the login was confirmed. Run 'dropsh auth login' to try again.";
78
+ return `Device login failed: ${code}.`;
79
+ }
80
+ /**
81
+ * RFC 8628 §3.4/§3.5: wait *before* every request, keep waiting on
82
+ * `authorization_pending`, raise the interval permanently on `slow_down`, and
83
+ * treat every other OAuth error as terminal so no code is polled forever. A
84
+ * transport failure carries no OAuth code, so it backs off and retries until the
85
+ * device code's own lifetime runs out.
86
+ */
87
+ async function pollForToken(deps, auth, signal) {
88
+ const wait = deps._sleep ?? sleep;
89
+ const deadline = deps.now() + auth.expires_in * 1000;
90
+ let intervalSec = Math.max(auth.interval ?? DEFAULT_INTERVAL_SEC, 1);
91
+ const params = new URLSearchParams({
92
+ grant_type: DEVICE_GRANT,
93
+ device_code: auth.device_code,
94
+ client_id: deps.clientId,
95
+ });
96
+ for (;;) {
97
+ await wait(intervalSec * 1000, signal);
98
+ if (deps.now() >= deadline)
99
+ throw new AuthError("The device code expired before the login was confirmed. " +
100
+ "Run 'dropsh auth login' to try again.", { reason: "expired_token", client_id: deps.clientId });
101
+ try {
102
+ const res = await deps.http.send({
103
+ method: "POST",
104
+ url: deps.tokenUrl,
105
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
106
+ body: params.toString(),
107
+ });
108
+ return parseTokenResponse(res.body, deps.now);
109
+ }
110
+ catch (err) {
111
+ if (err instanceof AuthError)
112
+ throw err;
113
+ if (!(err instanceof HttpError))
114
+ throw new AuthError(`Token endpoint response from ${deps.tokenUrl} is not valid.`, {
115
+ reason: "invalid_response",
116
+ client_id: deps.clientId,
117
+ });
118
+ const code = readOAuthError(err.body);
119
+ if (code === "authorization_pending")
120
+ continue;
121
+ if (code === "slow_down") {
122
+ intervalSec += SLOW_DOWN_INCREMENT_SEC;
123
+ continue;
124
+ }
125
+ if (code === undefined) {
126
+ // No OAuth code: a transport or connection failure. Poll less often
127
+ // rather than giving up — the deadline above still bounds the loop.
128
+ intervalSec += SLOW_DOWN_INCREMENT_SEC;
129
+ continue;
130
+ }
131
+ throw new AuthError(deviceErrorMessage(code), { reason: code, client_id: deps.clientId });
132
+ }
133
+ }
134
+ }
135
+ export async function acquireDeviceSession(deps) {
136
+ const auth = await requestDeviceAuthorization(deps);
137
+ announce(auth, deps.stdout);
138
+ // Ctrl+C during the wait must end the login with a clear message rather than a
139
+ // half-finished flow. The listener is temporary: once polling is over the CLI
140
+ // gets its default SIGINT behaviour back.
141
+ const controller = new AbortController();
142
+ const signal = deps.signal ?? controller.signal;
143
+ const sigintHandler = () => controller.abort();
144
+ process.on("SIGINT", sigintHandler);
145
+ // A cancellation promise with its listener registered up front, so an abort
146
+ // fired from inside a mocked/real wait call is still observed even when that
147
+ // wait call's own listener was registered too late (AbortSignal does not
148
+ // replay an event to a listener added after it already fired).
149
+ let rejectCancelled = () => { };
150
+ const cancelled = new Promise((_resolve, reject) => {
151
+ rejectCancelled = reject;
152
+ });
153
+ const onAbort = () => rejectCancelled(new AuthError("Login cancelled."));
154
+ if (signal.aborted)
155
+ onAbort();
156
+ else
157
+ signal.addEventListener("abort", onAbort, { once: true });
158
+ try {
159
+ return await Promise.race([pollForToken(deps, auth, signal), cancelled]);
160
+ }
161
+ finally {
162
+ process.off("SIGINT", sigintHandler);
163
+ signal.removeEventListener("abort", onAbort);
164
+ }
165
+ }
@@ -30,6 +30,12 @@ export type OAuth2Config = OAuth2Common & ({
30
30
  token_url: string;
31
31
  scope?: string;
32
32
  redirect_port?: number;
33
+ } | {
34
+ type: "oauth2_device_code";
35
+ client_id: string;
36
+ device_authorization_url: string;
37
+ token_url: string;
38
+ scope?: string;
33
39
  });
34
40
  export declare function oauth2Plugin(config: OAuth2Config): DropSHPlugin;
35
41
  export {};
@@ -12,10 +12,10 @@ import { oauth2Provider } from "./provider.js";
12
12
  * the scheme separator and must not be mistaken for the defect. A `token_url`
13
13
  * that does not parse as an absolute URL is left to the request layer.
14
14
  */
15
- function assertCanonicalTokenUrl(tokenUrl) {
15
+ function assertCanonicalUrl(field, value) {
16
16
  let parsed;
17
17
  try {
18
- parsed = new URL(tokenUrl);
18
+ parsed = new URL(value);
19
19
  }
20
20
  catch {
21
21
  return;
@@ -23,16 +23,21 @@ function assertCanonicalTokenUrl(tokenUrl) {
23
23
  if (!parsed.pathname.includes("//"))
24
24
  return;
25
25
  parsed.pathname = parsed.pathname.replace(/\/{2,}/g, "/");
26
- throw new ConfigError(`oauth2Plugin: token_url must not contain an empty path segment — set it to ${parsed.href}`);
26
+ throw new ConfigError(`oauth2Plugin: ${field} must not contain an empty path segment — set it to ${parsed.href}`);
27
27
  }
28
28
  function validate(config) {
29
29
  if (!config.client_id)
30
30
  throw new ConfigError("oauth2Plugin: client_id required");
31
31
  if (!config.token_url)
32
32
  throw new ConfigError("oauth2Plugin: token_url required");
33
- assertCanonicalTokenUrl(config.token_url);
33
+ assertCanonicalUrl("token_url", config.token_url);
34
34
  if (config.type === "oauth2_password" && !config.username)
35
35
  throw new ConfigError("oauth2Plugin: username required for oauth2_password");
36
+ if (config.type === "oauth2_device_code") {
37
+ if (!config.device_authorization_url)
38
+ throw new ConfigError("oauth2Plugin: device_authorization_url required for oauth2_device_code");
39
+ assertCanonicalUrl("device_authorization_url", config.device_authorization_url);
40
+ }
36
41
  return config;
37
42
  }
38
43
  export function oauth2Plugin(config) {
@@ -1,6 +1,7 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import { createServer } from "node:http";
3
3
  import { AuthError } from "dropsh/plugin";
4
+ import { parseTokenResponse } from "./token.js";
4
5
  const DEFAULT_PORT = 7432;
5
6
  const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
6
7
  export function generatePkce() {
@@ -101,13 +102,5 @@ export async function acquireAuthCodeSession(deps) {
101
102
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
102
103
  body: params.toString(),
103
104
  });
104
- const body = JSON.parse(res.body);
105
- if (typeof body.access_token !== "string")
106
- throw new AuthError("Token endpoint returned no access_token");
107
- const ttlSec = typeof body.expires_in === "number" ? body.expires_in : 3600;
108
- return {
109
- access_token: body.access_token,
110
- ...(typeof body.refresh_token === "string" ? { refresh_token: body.refresh_token } : {}),
111
- expires_at: deps.now() + ttlSec * 1000 - 5000,
112
- };
105
+ return parseTokenResponse(res.body, deps.now);
113
106
  }
@@ -1,36 +1,16 @@
1
1
  import { AuthError, HttpError } from "dropsh/plugin";
2
+ import { acquireDeviceSession } from "./device.js";
2
3
  import { acquireAuthCodeSession } from "./login.js";
4
+ import { parseTokenResponse, readOAuthError } from "./token.js";
3
5
  const DISPLAY = {
4
6
  oauth2_authcode: "OAuth 2.0 (browser login, PKCE)",
5
7
  oauth2_password: "OAuth 2.0 (resource owner password)",
6
8
  oauth2_client_credentials: "OAuth 2.0 (client credentials)",
9
+ oauth2_device_code: "OAuth 2.0 (device code)",
7
10
  };
8
11
  function bearer(req, token) {
9
12
  return { ...(req.headers ?? {}), Authorization: `Bearer ${token}` };
10
13
  }
11
- /**
12
- * Read the RFC 6749 `error` code from a token endpoint's error response. The
13
- * body may already be an object (as our tests and some clients provide) or the
14
- * raw JSON string the real http client carries; parse defensively and return
15
- * the `error` field only when it is a string.
16
- */
17
- function readOAuthError(body) {
18
- let parsed = body;
19
- if (typeof body === "string") {
20
- try {
21
- parsed = JSON.parse(body);
22
- }
23
- catch {
24
- return undefined;
25
- }
26
- }
27
- if (parsed && typeof parsed === "object" && "error" in parsed) {
28
- const code = parsed.error;
29
- if (typeof code === "string")
30
- return code;
31
- }
32
- return undefined;
33
- }
34
14
  async function postToken(http, url, params, now, identity) {
35
15
  try {
36
16
  const res = await http.send({
@@ -39,15 +19,7 @@ async function postToken(http, url, params, now, identity) {
39
19
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
40
20
  body: params.toString(),
41
21
  });
42
- const body = JSON.parse(res.body);
43
- if (typeof body.access_token !== "string")
44
- throw new AuthError("Token endpoint returned no access_token");
45
- const ttlSec = typeof body.expires_in === "number" ? body.expires_in : 3600;
46
- return {
47
- access_token: body.access_token,
48
- ...(typeof body.refresh_token === "string" ? { refresh_token: body.refresh_token } : {}),
49
- expires_at: now() + ttlSec * 1000 - 5000,
50
- };
22
+ return parseTokenResponse(res.body, now);
51
23
  }
52
24
  catch (err) {
53
25
  if (err instanceof AuthError)
@@ -121,6 +93,17 @@ export function oauth2Provider(cfg) {
121
93
  now: ctx.now,
122
94
  });
123
95
  }
96
+ if (cfg.type === "oauth2_device_code") {
97
+ return acquireDeviceSession({
98
+ clientId: cfg.client_id,
99
+ deviceAuthorizationUrl: cfg.device_authorization_url,
100
+ tokenUrl: cfg.token_url,
101
+ ...(cfg.scope !== undefined ? { scope: cfg.scope } : {}),
102
+ http: ctx.http,
103
+ stdout: ctx.stdout,
104
+ now: ctx.now,
105
+ });
106
+ }
124
107
  // The client_secret is config, not a runtime credential: require it up
125
108
  // front (throws before any token request if unset/empty) rather than
126
109
  // prompting for it or posting an empty secret.
@@ -190,7 +173,7 @@ export function oauth2Provider(cfg) {
190
173
  await rt.save(minted);
191
174
  }
192
175
  async function renewSession() {
193
- if (cfg.type === "oauth2_authcode") {
176
+ if (cfg.type === "oauth2_authcode" || cfg.type === "oauth2_device_code") {
194
177
  const refresh = current.refresh_token;
195
178
  if (typeof refresh !== "string")
196
179
  throw new AuthError("Session expired. Run 'dropsh auth login'.");
@@ -0,0 +1,14 @@
1
+ import type { AuthSession } from "dropsh/plugin";
2
+ /**
3
+ * Parse an RFC 6749 token response body into an AuthSession. Shared by the
4
+ * authcode, device and client-credentials paths so the TTL margin and the
5
+ * missing-access_token guard exist once.
6
+ */
7
+ export declare function parseTokenResponse(rawBody: string, now: () => number): AuthSession;
8
+ /**
9
+ * Read the RFC 6749 `error` code from an error response. The body may already be
10
+ * an object (as our tests and some clients provide) or the raw JSON string the
11
+ * real http client carries; parse defensively and return the `error` field only
12
+ * when it is a string.
13
+ */
14
+ export declare function readOAuthError(body: unknown): string | undefined;
@@ -0,0 +1,40 @@
1
+ import { AuthError } from "dropsh/plugin";
2
+ /**
3
+ * Parse an RFC 6749 token response body into an AuthSession. Shared by the
4
+ * authcode, device and client-credentials paths so the TTL margin and the
5
+ * missing-access_token guard exist once.
6
+ */
7
+ export function parseTokenResponse(rawBody, now) {
8
+ const body = JSON.parse(rawBody);
9
+ if (typeof body.access_token !== "string")
10
+ throw new AuthError("Token endpoint returned no access_token");
11
+ const ttlSec = typeof body.expires_in === "number" ? body.expires_in : 3600;
12
+ return {
13
+ access_token: body.access_token,
14
+ ...(typeof body.refresh_token === "string" ? { refresh_token: body.refresh_token } : {}),
15
+ expires_at: now() + ttlSec * 1000 - 5000,
16
+ };
17
+ }
18
+ /**
19
+ * Read the RFC 6749 `error` code from an error response. The body may already be
20
+ * an object (as our tests and some clients provide) or the raw JSON string the
21
+ * real http client carries; parse defensively and return the `error` field only
22
+ * when it is a string.
23
+ */
24
+ export function readOAuthError(body) {
25
+ let parsed = body;
26
+ if (typeof body === "string") {
27
+ try {
28
+ parsed = JSON.parse(body);
29
+ }
30
+ catch {
31
+ return undefined;
32
+ }
33
+ }
34
+ if (parsed && typeof parsed === "object" && "error" in parsed) {
35
+ const code = parsed.error;
36
+ if (typeof code === "string")
37
+ return code;
38
+ }
39
+ return undefined;
40
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dropsh/plugin-oauth2",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "type": "module",
5
5
  "main": "./dist/plugins/oauth2/src/index.js",
6
6
  "types": "./dist/plugins/oauth2/src/index.d.ts",
@@ -23,7 +23,7 @@
23
23
  "devDependencies": {
24
24
  "typescript": "^5.6.0",
25
25
  "vitest": "^4.1.8",
26
- "dropsh": "0.7.1"
26
+ "dropsh": "0.7.3"
27
27
  },
28
28
  "scripts": {
29
29
  "build": "tsc",