@dropsh/plugin-oauth2 0.7.2 → 0.7.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/README.md +36 -1
- package/dist/plugins/oauth2/src/device.d.ts +15 -0
- package/dist/plugins/oauth2/src/device.js +165 -0
- package/dist/plugins/oauth2/src/index.d.ts +6 -0
- package/dist/plugins/oauth2/src/index.js +9 -4
- package/dist/plugins/oauth2/src/login.js +2 -9
- package/dist/plugins/oauth2/src/provider.js +41 -34
- package/dist/plugins/oauth2/src/token.d.ts +14 -0
- package/dist/plugins/oauth2/src/token.js +40 -0
- package/package.json +2 -2
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
|
|
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
|
|
15
|
+
function assertCanonicalUrl(field, value) {
|
|
16
16
|
let parsed;
|
|
17
17
|
try {
|
|
18
|
-
parsed = new URL(
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
@@ -189,12 +172,36 @@ export function oauth2Provider(cfg) {
|
|
|
189
172
|
current = minted;
|
|
190
173
|
await rt.save(minted);
|
|
191
174
|
}
|
|
175
|
+
// The session slot is shared by every process of this user (conductor, ui,
|
|
176
|
+
// agent CLI calls), and the server rotates the refresh token on each use.
|
|
177
|
+
// Adopt what another process stored instead of refreshing with a revoked token;
|
|
178
|
+
// returns true when the adopted access token is still valid.
|
|
179
|
+
async function adoptStored() {
|
|
180
|
+
const stored = await rt.load?.();
|
|
181
|
+
if (!stored ||
|
|
182
|
+
stored.access_token === current.access_token ||
|
|
183
|
+
typeof stored.refresh_token !== "string")
|
|
184
|
+
return false;
|
|
185
|
+
current = stored;
|
|
186
|
+
const expiresAt = typeof stored.expires_at === "number" ? stored.expires_at : 0;
|
|
187
|
+
return expiresAt - 30_000 > rt.now();
|
|
188
|
+
}
|
|
192
189
|
async function renewSession() {
|
|
193
|
-
if (cfg.type === "oauth2_authcode") {
|
|
190
|
+
if (cfg.type === "oauth2_authcode" || cfg.type === "oauth2_device_code") {
|
|
191
|
+
if (await adoptStored())
|
|
192
|
+
return;
|
|
194
193
|
const refresh = current.refresh_token;
|
|
195
194
|
if (typeof refresh !== "string")
|
|
196
195
|
throw new AuthError("Session expired. Run 'dropsh auth login'.");
|
|
197
|
-
|
|
196
|
+
try {
|
|
197
|
+
await refreshAuthcode(refresh);
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
200
|
+
// Lost the race: another process rotated the token between load and post.
|
|
201
|
+
if (await adoptStored())
|
|
202
|
+
return;
|
|
203
|
+
throw err;
|
|
204
|
+
}
|
|
198
205
|
return;
|
|
199
206
|
}
|
|
200
207
|
if (cfg.type === "oauth2_client_credentials") {
|
|
@@ -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.
|
|
3
|
+
"version": "0.7.5",
|
|
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.
|
|
26
|
+
"dropsh": "0.7.5"
|
|
27
27
|
},
|
|
28
28
|
"scripts": {
|
|
29
29
|
"build": "tsc",
|