@dropsh/plugin-oauth2 0.0.2
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/LICENSE +51 -0
- package/README.md +71 -0
- package/dist/plugins/oauth2/src/index.js +22 -0
- package/dist/plugins/oauth2/src/login.js +113 -0
- package/dist/plugins/oauth2/src/oauth2-authcode.js +64 -0
- package/dist/plugins/oauth2/src/oauth2.js +56 -0
- package/dist/plugins/oauth2/src/provider.js +133 -0
- package/dist/plugins/oauth2/src/token-store.js +26 -0
- package/package.json +29 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
drupal-cli is dual-licensed. You may use this software under the terms of
|
|
2
|
+
either the MIT License or the GNU General Public License version 2 or any
|
|
3
|
+
later version, at your option.
|
|
4
|
+
|
|
5
|
+
SPDX-License-Identifier: (MIT OR GPL-2.0-or-later)
|
|
6
|
+
|
|
7
|
+
================================================================================
|
|
8
|
+
MIT License
|
|
9
|
+
================================================================================
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 Christian Wiedemann
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
|
|
31
|
+
================================================================================
|
|
32
|
+
GNU General Public License, version 2 or later
|
|
33
|
+
================================================================================
|
|
34
|
+
|
|
35
|
+
The full text of the GNU GPL v2 is available at:
|
|
36
|
+
https://www.gnu.org/licenses/old-licenses/gpl-2.0.txt
|
|
37
|
+
|
|
38
|
+
The full text of any later GPL version is available at:
|
|
39
|
+
https://www.gnu.org/licenses/
|
|
40
|
+
|
|
41
|
+
Copyright (C) 2026 Christian Wiedemann
|
|
42
|
+
|
|
43
|
+
This program is free software; you can redistribute it and/or modify it under
|
|
44
|
+
the terms of the GNU General Public License as published by the Free Software
|
|
45
|
+
Foundation; either version 2 of the License, or (at your option) any later
|
|
46
|
+
version.
|
|
47
|
+
|
|
48
|
+
This program is distributed in the hope that it will be useful, but WITHOUT
|
|
49
|
+
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
|
50
|
+
FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
|
51
|
+
details.
|
package/README.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# @dropsh/plugin-oauth2
|
|
2
|
+
|
|
3
|
+
OAuth 2.0 authentication provider for [dropsh](https://github.com/pen-drop/dropsh),
|
|
4
|
+
the entity-agnostic CLI for Drupal 11 JSON:API.
|
|
5
|
+
|
|
6
|
+
Adds an OAuth2 login option to `dropsh auth login`. Supports three grant types
|
|
7
|
+
against Drupal's `simple_oauth` module.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pnpm add @dropsh/plugin-oauth2
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`dropsh` is a peer dependency.
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
Register the plugin in `dropsh.config.js` with **non-secret** connection
|
|
20
|
+
parameters only. Secrets (client secret, user password) and tokens are prompted
|
|
21
|
+
at login and stored at `~/.config/dropsh/<host>.json` (mode `0600`) — never in
|
|
22
|
+
the config file.
|
|
23
|
+
|
|
24
|
+
```js
|
|
25
|
+
import { oauth2Plugin } from "@dropsh/plugin-oauth2";
|
|
26
|
+
|
|
27
|
+
export default {
|
|
28
|
+
site: { base_url: "https://my-drupal.example.com", jsonapi_prefix: "/jsonapi" },
|
|
29
|
+
plugins: [
|
|
30
|
+
// Browser login (Authorization Code + PKCE) — recommended for humans:
|
|
31
|
+
oauth2Plugin({
|
|
32
|
+
type: "oauth2_authcode",
|
|
33
|
+
client_id: "my-client",
|
|
34
|
+
token_url: "https://my-drupal.example.com/oauth/token",
|
|
35
|
+
// scope: "...", redirect_port: 7432,
|
|
36
|
+
}),
|
|
37
|
+
],
|
|
38
|
+
};
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Then:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
dropsh auth login --provider oauth2_authcode
|
|
45
|
+
dropsh auth status
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Grant types
|
|
49
|
+
|
|
50
|
+
| `type` | Interactive login prompts | Token refresh |
|
|
51
|
+
| ----------------------------- | -------------------------------- | -------------------- |
|
|
52
|
+
| `oauth2_authcode` | opens browser (PKCE, public client) | yes (refresh token) |
|
|
53
|
+
| `oauth2_password` | client secret + user password | re-login on expiry |
|
|
54
|
+
| `oauth2_client_credentials` | client secret | re-login on expiry |
|
|
55
|
+
|
|
56
|
+
Config fields per grant:
|
|
57
|
+
|
|
58
|
+
- `oauth2_authcode` — `client_id`, `token_url`, optional `scope`, `redirect_port`
|
|
59
|
+
- `oauth2_password` — `client_id`, `token_url`, `username`, optional `scope`
|
|
60
|
+
- `oauth2_client_credentials` — `client_id`, `token_url`, optional `scope`
|
|
61
|
+
|
|
62
|
+
`client_secret` and the user `password` are **prompted at login**, never stored
|
|
63
|
+
in config.
|
|
64
|
+
|
|
65
|
+
## Required Drupal module
|
|
66
|
+
|
|
67
|
+
- [`simple_oauth`](https://www.drupal.org/project/simple_oauth)
|
|
68
|
+
|
|
69
|
+
## License
|
|
70
|
+
|
|
71
|
+
MIT OR GPL-2.0-or-later
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { ConfigError } from "dropsh/plugin";
|
|
2
|
+
import { oauth2Provider } from "./provider.js";
|
|
3
|
+
function validate(config) {
|
|
4
|
+
if (!config.client_id)
|
|
5
|
+
throw new ConfigError("oauth2Plugin: client_id required");
|
|
6
|
+
if (!config.token_url)
|
|
7
|
+
throw new ConfigError("oauth2Plugin: token_url required");
|
|
8
|
+
if (config.type === "oauth2_password" && !config.username)
|
|
9
|
+
throw new ConfigError("oauth2Plugin: username required for oauth2_password");
|
|
10
|
+
return config;
|
|
11
|
+
}
|
|
12
|
+
export function oauth2Plugin(config) {
|
|
13
|
+
const cfg = validate(config);
|
|
14
|
+
return {
|
|
15
|
+
id: "oauth2",
|
|
16
|
+
requiredModules: ["simple_oauth"],
|
|
17
|
+
authProvider: oauth2Provider(cfg),
|
|
18
|
+
async extendSchema(_entityType, _bundle, schema, _ctx) {
|
|
19
|
+
return schema;
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { AuthError } from "dropsh/plugin";
|
|
4
|
+
const DEFAULT_PORT = 7432;
|
|
5
|
+
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
|
6
|
+
export function generatePkce() {
|
|
7
|
+
const verifier = randomBytes(96).toString("base64url");
|
|
8
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
9
|
+
return { verifier, challenge };
|
|
10
|
+
}
|
|
11
|
+
export function generateState() {
|
|
12
|
+
return randomBytes(16).toString("base64url");
|
|
13
|
+
}
|
|
14
|
+
function waitForCallbackCode(c) {
|
|
15
|
+
const { port, state, timeoutMs, authUrl } = c;
|
|
16
|
+
const stdout = c.deps.stdout;
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
let settled = false;
|
|
19
|
+
const finish = (fn) => {
|
|
20
|
+
if (settled)
|
|
21
|
+
return;
|
|
22
|
+
settled = true;
|
|
23
|
+
clearTimeout(timer);
|
|
24
|
+
server.close(() => fn());
|
|
25
|
+
};
|
|
26
|
+
const server = createServer((req, res) => {
|
|
27
|
+
const url = new URL(req.url ?? "/", `http://localhost:${port}`);
|
|
28
|
+
if (url.pathname !== "/callback") {
|
|
29
|
+
res.writeHead(404);
|
|
30
|
+
res.end();
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
34
|
+
res.end("<html><body><p>Login successful. You can close this tab.</p></body></html>");
|
|
35
|
+
const receivedState = url.searchParams.get("state");
|
|
36
|
+
const receivedCode = url.searchParams.get("code");
|
|
37
|
+
if (receivedState !== state) {
|
|
38
|
+
finish(() => reject(new AuthError("OAuth state mismatch — possible CSRF attack")));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (!receivedCode) {
|
|
42
|
+
finish(() => reject(new AuthError("No authorization code received")));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
finish(() => resolve(receivedCode));
|
|
46
|
+
});
|
|
47
|
+
server.on("error", (err) => {
|
|
48
|
+
if (err.code === "EADDRINUSE") {
|
|
49
|
+
finish(() => reject(new AuthError(`Port ${port} is already in use. A previous login may still be running — kill it and retry.`)));
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
finish(() => reject(err));
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
const timer = setTimeout(() => {
|
|
56
|
+
finish(() => reject(new AuthError("Login timed out. Run 'dropsh auth login' to try again.")));
|
|
57
|
+
}, timeoutMs);
|
|
58
|
+
server.listen(port, () => {
|
|
59
|
+
stdout("Opening browser for login...");
|
|
60
|
+
stdout(`If the browser does not open, visit:\n${authUrl.toString()}`);
|
|
61
|
+
c.deps.openBrowser(authUrl.toString()).catch(() => {
|
|
62
|
+
// URL is printed above — user can open it manually.
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/** Runs the PKCE browser flow and returns an AuthSession (no persistence). */
|
|
68
|
+
export async function acquireAuthCodeSession(deps) {
|
|
69
|
+
const timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
70
|
+
const port = deps.redirectPort ?? DEFAULT_PORT;
|
|
71
|
+
const pkce = (deps._generatePkce ?? generatePkce)();
|
|
72
|
+
const state = (deps._generateState ?? generateState)();
|
|
73
|
+
const baseUrl = deps.baseUrl.replace(/\/$/, "");
|
|
74
|
+
const redirectUri = `http://localhost:${port}/callback`;
|
|
75
|
+
const authUrl = new URL(`${baseUrl}/oauth/authorize`);
|
|
76
|
+
authUrl.searchParams.set("response_type", "code");
|
|
77
|
+
authUrl.searchParams.set("client_id", deps.clientId);
|
|
78
|
+
authUrl.searchParams.set("redirect_uri", redirectUri);
|
|
79
|
+
authUrl.searchParams.set("code_challenge", pkce.challenge);
|
|
80
|
+
authUrl.searchParams.set("code_challenge_method", "S256");
|
|
81
|
+
authUrl.searchParams.set("state", state);
|
|
82
|
+
if (deps.scope)
|
|
83
|
+
authUrl.searchParams.set("scope", deps.scope);
|
|
84
|
+
const code = await waitForCallbackCode({
|
|
85
|
+
port,
|
|
86
|
+
state,
|
|
87
|
+
timeoutMs,
|
|
88
|
+
authUrl,
|
|
89
|
+
deps: { openBrowser: deps.openBrowser, stdout: deps.stdout },
|
|
90
|
+
});
|
|
91
|
+
const params = new URLSearchParams({
|
|
92
|
+
grant_type: "authorization_code",
|
|
93
|
+
code,
|
|
94
|
+
code_verifier: pkce.verifier,
|
|
95
|
+
client_id: deps.clientId,
|
|
96
|
+
redirect_uri: redirectUri,
|
|
97
|
+
});
|
|
98
|
+
const res = await deps.http.send({
|
|
99
|
+
method: "POST",
|
|
100
|
+
url: deps.tokenUrl,
|
|
101
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
102
|
+
body: params.toString(),
|
|
103
|
+
});
|
|
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
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { AuthError, createHttpClient, HttpError } from "dropsh/plugin";
|
|
2
|
+
import { readToken, writeToken } from "./token-store.js";
|
|
3
|
+
export function createOAuth2AuthCodeAuth(cfg, deps = {}) {
|
|
4
|
+
const now = deps.now ?? Date.now;
|
|
5
|
+
const http = deps.http ?? createHttpClient();
|
|
6
|
+
// Derive base URL for token storage key
|
|
7
|
+
const baseUrl = cfg.token_url.replace(/\/oauth\/token$/, "");
|
|
8
|
+
return {
|
|
9
|
+
async apply(req) {
|
|
10
|
+
const stored = await readToken(baseUrl, deps.tokenDir);
|
|
11
|
+
if (!stored) {
|
|
12
|
+
throw new AuthError("Session expired. Run 'dropsh login' to authenticate.");
|
|
13
|
+
}
|
|
14
|
+
if (stored.expires_at - 30_000 > now()) {
|
|
15
|
+
return {
|
|
16
|
+
...req,
|
|
17
|
+
headers: { ...(req.headers ?? {}), Authorization: `Bearer ${stored.access_token}` },
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
if (!stored.refresh_token) {
|
|
21
|
+
throw new AuthError("Session expired. Run 'dropsh login' to authenticate.");
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
const params = new URLSearchParams({
|
|
25
|
+
grant_type: "refresh_token",
|
|
26
|
+
refresh_token: stored.refresh_token,
|
|
27
|
+
client_id: cfg.client_id,
|
|
28
|
+
});
|
|
29
|
+
const res = await http.send({
|
|
30
|
+
method: "POST",
|
|
31
|
+
url: cfg.token_url,
|
|
32
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
33
|
+
body: params.toString(),
|
|
34
|
+
});
|
|
35
|
+
const body = JSON.parse(res.body);
|
|
36
|
+
if (typeof body.access_token !== "string") {
|
|
37
|
+
throw new AuthError("Token endpoint returned no access_token");
|
|
38
|
+
}
|
|
39
|
+
const ttlSec = typeof body.expires_in === "number" ? body.expires_in : 3600;
|
|
40
|
+
const refreshed = {
|
|
41
|
+
access_token: body.access_token,
|
|
42
|
+
refresh_token: typeof body.refresh_token === "string" ? body.refresh_token : stored.refresh_token,
|
|
43
|
+
expires_at: now() + ttlSec * 1000 - 5000,
|
|
44
|
+
};
|
|
45
|
+
await writeToken(baseUrl, refreshed, deps.tokenDir);
|
|
46
|
+
return {
|
|
47
|
+
...req,
|
|
48
|
+
headers: { ...(req.headers ?? {}), Authorization: `Bearer ${refreshed.access_token}` },
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
if (err instanceof AuthError)
|
|
53
|
+
throw err;
|
|
54
|
+
if (err instanceof HttpError) {
|
|
55
|
+
throw new AuthError("Session expired. Run 'dropsh login' to authenticate.", {
|
|
56
|
+
status: err.status,
|
|
57
|
+
body: err.body,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
throw new AuthError("Session expired. Run 'dropsh login' to authenticate.");
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { AuthError, createHttpClient, HttpError } from "dropsh/plugin";
|
|
2
|
+
export function createOAuth2Auth(cfg, deps = {}) {
|
|
3
|
+
const now = deps.now ?? Date.now;
|
|
4
|
+
const http = deps.http ?? createHttpClient();
|
|
5
|
+
const params = new URLSearchParams({
|
|
6
|
+
client_id: cfg.client_id,
|
|
7
|
+
client_secret: cfg.client_secret,
|
|
8
|
+
});
|
|
9
|
+
if (cfg.type === "oauth2_password") {
|
|
10
|
+
params.set("grant_type", "password");
|
|
11
|
+
params.set("username", cfg.username);
|
|
12
|
+
params.set("password", cfg.password);
|
|
13
|
+
if (cfg.scope)
|
|
14
|
+
params.set("scope", cfg.scope);
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
params.set("grant_type", "client_credentials");
|
|
18
|
+
if (cfg.scope)
|
|
19
|
+
params.set("scope", cfg.scope);
|
|
20
|
+
}
|
|
21
|
+
let cached = null;
|
|
22
|
+
async function fetchToken() {
|
|
23
|
+
try {
|
|
24
|
+
const res = await http.send({
|
|
25
|
+
method: "POST",
|
|
26
|
+
url: cfg.token_url,
|
|
27
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
28
|
+
body: params.toString(),
|
|
29
|
+
});
|
|
30
|
+
const body = JSON.parse(res.body);
|
|
31
|
+
if (typeof body.access_token !== "string") {
|
|
32
|
+
throw new AuthError("Token endpoint returned no access_token");
|
|
33
|
+
}
|
|
34
|
+
const ttlSec = typeof body.expires_in === "number" ? body.expires_in : 3600;
|
|
35
|
+
return { token: body.access_token, expiresAt: now() + ttlSec * 1000 - 5000 };
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
if (err instanceof HttpError) {
|
|
39
|
+
throw new AuthError(`Token request failed: HTTP ${err.status}`, { body: err.body });
|
|
40
|
+
}
|
|
41
|
+
if (err instanceof AuthError)
|
|
42
|
+
throw err;
|
|
43
|
+
throw new AuthError(`Token request failed: ${err.message}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
async apply(req) {
|
|
48
|
+
if (!cached || cached.expiresAt <= now())
|
|
49
|
+
cached = await fetchToken();
|
|
50
|
+
return {
|
|
51
|
+
...req,
|
|
52
|
+
headers: { ...(req.headers ?? {}), Authorization: `Bearer ${cached.token}` },
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { AuthError, HttpError } from "dropsh/plugin";
|
|
2
|
+
import { acquireAuthCodeSession } from "./login.js";
|
|
3
|
+
const DISPLAY = {
|
|
4
|
+
oauth2_authcode: "OAuth 2.0 (browser login, PKCE)",
|
|
5
|
+
oauth2_password: "OAuth 2.0 (resource owner password)",
|
|
6
|
+
oauth2_client_credentials: "OAuth 2.0 (client credentials)",
|
|
7
|
+
};
|
|
8
|
+
function bearer(req, token) {
|
|
9
|
+
return { ...(req.headers ?? {}), Authorization: `Bearer ${token}` };
|
|
10
|
+
}
|
|
11
|
+
async function postToken(http, url, params, now) {
|
|
12
|
+
try {
|
|
13
|
+
const res = await http.send({
|
|
14
|
+
method: "POST",
|
|
15
|
+
url,
|
|
16
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
17
|
+
body: params.toString(),
|
|
18
|
+
});
|
|
19
|
+
const body = JSON.parse(res.body);
|
|
20
|
+
if (typeof body.access_token !== "string")
|
|
21
|
+
throw new AuthError("Token endpoint returned no access_token");
|
|
22
|
+
const ttlSec = typeof body.expires_in === "number" ? body.expires_in : 3600;
|
|
23
|
+
return {
|
|
24
|
+
access_token: body.access_token,
|
|
25
|
+
...(typeof body.refresh_token === "string" ? { refresh_token: body.refresh_token } : {}),
|
|
26
|
+
expires_at: now() + ttlSec * 1000 - 5000,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
if (err instanceof AuthError)
|
|
31
|
+
throw err;
|
|
32
|
+
if (err instanceof HttpError)
|
|
33
|
+
throw new AuthError(`Token request failed: HTTP ${err.status}`, { body: err.body });
|
|
34
|
+
throw new AuthError(`Token request failed: ${err.message}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function oauth2Provider(cfg) {
|
|
38
|
+
return {
|
|
39
|
+
id: cfg.type,
|
|
40
|
+
displayName: DISPLAY[cfg.type],
|
|
41
|
+
capabilities: { login: true, logout: true, status: true },
|
|
42
|
+
async login(ctx) {
|
|
43
|
+
if (cfg.type === "oauth2_authcode") {
|
|
44
|
+
return acquireAuthCodeSession({
|
|
45
|
+
baseUrl: ctx.baseUrl,
|
|
46
|
+
clientId: cfg.client_id,
|
|
47
|
+
tokenUrl: cfg.token_url,
|
|
48
|
+
...(cfg.scope !== undefined ? { scope: cfg.scope } : {}),
|
|
49
|
+
...(cfg.redirect_port !== undefined ? { redirectPort: cfg.redirect_port } : {}),
|
|
50
|
+
http: ctx.http,
|
|
51
|
+
openBrowser: ctx.openBrowser,
|
|
52
|
+
stdout: ctx.stdout,
|
|
53
|
+
now: ctx.now,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
const params = new URLSearchParams({ client_id: cfg.client_id });
|
|
57
|
+
const secret = await ctx.prompt({ label: "Client secret", secret: true });
|
|
58
|
+
params.set("client_secret", secret);
|
|
59
|
+
if (cfg.type === "oauth2_password") {
|
|
60
|
+
const password = await ctx.prompt({ label: "Password", secret: true });
|
|
61
|
+
params.set("grant_type", "password");
|
|
62
|
+
params.set("username", cfg.username);
|
|
63
|
+
params.set("password", password);
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
params.set("grant_type", "client_credentials");
|
|
67
|
+
}
|
|
68
|
+
if (cfg.scope)
|
|
69
|
+
params.set("scope", cfg.scope);
|
|
70
|
+
return postToken(ctx.http, cfg.token_url, params, ctx.now);
|
|
71
|
+
},
|
|
72
|
+
async logout() {
|
|
73
|
+
// simple_oauth has no standard revoke endpoint wired; core clears the session.
|
|
74
|
+
},
|
|
75
|
+
async status(session) {
|
|
76
|
+
if (!session)
|
|
77
|
+
return { loggedIn: false, provider: cfg.type };
|
|
78
|
+
const expiresAt = typeof session.expires_at === "number" ? session.expires_at : undefined;
|
|
79
|
+
const info = {
|
|
80
|
+
loggedIn: true,
|
|
81
|
+
provider: cfg.type,
|
|
82
|
+
};
|
|
83
|
+
if (expiresAt !== undefined) {
|
|
84
|
+
info.expiresAt = expiresAt;
|
|
85
|
+
info.state = expiresAt > Date.now() ? "valid" : "expired";
|
|
86
|
+
}
|
|
87
|
+
return info;
|
|
88
|
+
},
|
|
89
|
+
createAdapter(session, rt) {
|
|
90
|
+
// Mutable reference to the live session, updated in place after a refresh
|
|
91
|
+
// so subsequent requests reuse the new token instead of re-refreshing.
|
|
92
|
+
let current = session;
|
|
93
|
+
let refreshing = null;
|
|
94
|
+
async function doRefresh(refreshToken) {
|
|
95
|
+
const params = new URLSearchParams({
|
|
96
|
+
grant_type: "refresh_token",
|
|
97
|
+
refresh_token: refreshToken,
|
|
98
|
+
client_id: cfg.client_id,
|
|
99
|
+
});
|
|
100
|
+
const refreshed = await postToken(rt.http, cfg.token_url, params, rt.now);
|
|
101
|
+
if (typeof refreshed.refresh_token !== "string")
|
|
102
|
+
refreshed.refresh_token = refreshToken;
|
|
103
|
+
current = refreshed;
|
|
104
|
+
await rt.save(refreshed);
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
async apply(req) {
|
|
108
|
+
const token = current.access_token;
|
|
109
|
+
const expiresAt = typeof current.expires_at === "number" ? current.expires_at : 0;
|
|
110
|
+
if (typeof token === "string" && expiresAt - 30_000 > rt.now())
|
|
111
|
+
return { ...req, headers: bearer(req, token) };
|
|
112
|
+
// Only the authcode grant uses a public client (PKCE) and can refresh
|
|
113
|
+
// with client_id alone. For oauth2_password / oauth2_client_credentials
|
|
114
|
+
// the client is confidential and a refresh without client_secret would
|
|
115
|
+
// 401; we do not persist the long-lived client_secret at rest, so a
|
|
116
|
+
// re-login is required instead.
|
|
117
|
+
if (cfg.type !== "oauth2_authcode")
|
|
118
|
+
throw new AuthError("Session expired. Run 'dropsh auth login'.");
|
|
119
|
+
const refresh = current.refresh_token;
|
|
120
|
+
if (typeof refresh !== "string")
|
|
121
|
+
throw new AuthError("Session expired. Run 'dropsh auth login'.");
|
|
122
|
+
// Coalesce concurrent refreshes so we exchange the token only once.
|
|
123
|
+
if (!refreshing)
|
|
124
|
+
refreshing = doRefresh(refresh).finally(() => {
|
|
125
|
+
refreshing = null;
|
|
126
|
+
});
|
|
127
|
+
await refreshing;
|
|
128
|
+
return { ...req, headers: bearer(req, current.access_token) };
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
function hostnameFromUrl(baseUrl) {
|
|
5
|
+
return new URL(baseUrl).hostname;
|
|
6
|
+
}
|
|
7
|
+
export function defaultTokenDir() {
|
|
8
|
+
return join(homedir(), ".config", "dropsh");
|
|
9
|
+
}
|
|
10
|
+
export async function readToken(baseUrl, dir) {
|
|
11
|
+
const tokenDir = dir ?? defaultTokenDir();
|
|
12
|
+
const tokenPath = join(tokenDir, `${hostnameFromUrl(baseUrl)}.json`);
|
|
13
|
+
try {
|
|
14
|
+
const raw = await readFile(tokenPath, "utf8");
|
|
15
|
+
return JSON.parse(raw);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export async function writeToken(baseUrl, token, dir) {
|
|
22
|
+
const tokenDir = dir ?? defaultTokenDir();
|
|
23
|
+
await mkdir(tokenDir, { recursive: true });
|
|
24
|
+
const tokenPath = join(tokenDir, `${hostnameFromUrl(baseUrl)}.json`);
|
|
25
|
+
await writeFile(tokenPath, JSON.stringify(token, null, 2), { encoding: "utf8", mode: 0o600 });
|
|
26
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dropsh/plugin-oauth2",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./dist/plugins/oauth2/src/index.js",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./dist/plugins/oauth2/src/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist/plugins/oauth2/src",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"peerDependencies": {
|
|
17
|
+
"dropsh": "*"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"typescript": "^5.6.0",
|
|
21
|
+
"vitest": "^4.1.8",
|
|
22
|
+
"dropsh": "0.0.2"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsc",
|
|
26
|
+
"test": "vitest run --passWithNoTests",
|
|
27
|
+
"typecheck": "tsc --noEmit"
|
|
28
|
+
}
|
|
29
|
+
}
|