@dropsh/plugin-oauth2 0.2.0 → 0.3.1
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 +29 -10
- package/dist/plugins/oauth2/src/provider.js +60 -23
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -45,22 +45,41 @@ dropsh auth login --provider oauth2_authcode
|
|
|
45
45
|
dropsh auth status
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
+
## Named profiles
|
|
49
|
+
|
|
50
|
+
Every `oauth2Plugin` is one auth **profile**, identified by its `id`. `id` defaults
|
|
51
|
+
to the grant `type`, but set it explicitly to run several profiles of the same
|
|
52
|
+
grant flow side by side (e.g. two `client_credentials` with different scopes).
|
|
53
|
+
Mark one profile `default: true` to make it the fallback when none is selected.
|
|
54
|
+
|
|
55
|
+
```js
|
|
56
|
+
oauth2Plugin({ id: "session", default: true, type: "oauth2_client_credentials",
|
|
57
|
+
client_id: "my-client", client_secret, token_url, scope: "some:scope" }),
|
|
58
|
+
oauth2Plugin({ id: "pm", type: "oauth2_client_credentials",
|
|
59
|
+
client_id: "my-client", client_secret, token_url, scope: "other:scope" }),
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
See the [dropsh README](../../README.md#named-profiles--many-identities-per-host)
|
|
63
|
+
for `auth use`, `--auth-profile`, and selection precedence.
|
|
64
|
+
|
|
48
65
|
## Grant types
|
|
49
66
|
|
|
50
|
-
| `type` | Interactive login prompts |
|
|
51
|
-
| ----------------------------- | -------------------------------- |
|
|
52
|
-
| `oauth2_authcode` | opens browser (PKCE, public client) | yes
|
|
53
|
-
| `oauth2_password` | client secret + user password |
|
|
54
|
-
| `oauth2_client_credentials` | client secret |
|
|
67
|
+
| `type` | Interactive login prompts | Auto-renew (proactive + on 401) |
|
|
68
|
+
| ----------------------------- | -------------------------------- | ------------------------------- |
|
|
69
|
+
| `oauth2_authcode` | opens browser (PKCE, public client) | yes — via `refresh_token` |
|
|
70
|
+
| `oauth2_password` | client secret + user password | yes — if `client_secret` in config |
|
|
71
|
+
| `oauth2_client_credentials` | client secret | yes — if `client_secret` in config |
|
|
55
72
|
|
|
56
|
-
Config fields per grant:
|
|
73
|
+
Config fields per grant (all accept the shared `id` and `default`):
|
|
57
74
|
|
|
58
75
|
- `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`
|
|
76
|
+
- `oauth2_password` — `client_id`, `token_url`, `username`, optional `scope`, `client_secret`
|
|
77
|
+
- `oauth2_client_credentials` — `client_id`, `token_url`, optional `scope`, `client_secret`
|
|
61
78
|
|
|
62
|
-
`client_secret` and the user `password`
|
|
63
|
-
in config
|
|
79
|
+
`client_secret` and the user `password` may be **prompted at login** and are never
|
|
80
|
+
stored on disk. For unattended runs, put `client_secret` in config so dropsh can
|
|
81
|
+
re-mint tokens automatically; a purely prompted secret cannot be renewed and needs
|
|
82
|
+
a fresh `dropsh auth login` when the token expires.
|
|
64
83
|
|
|
65
84
|
## Required Drupal module
|
|
66
85
|
|
|
@@ -35,9 +35,13 @@ async function postToken(http, url, params, now) {
|
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
export function oauth2Provider(cfg) {
|
|
38
|
+
// Profile identity is `cfg.id` (falling back to `cfg.type`); `cfg.type` stays
|
|
39
|
+
// purely the grant-flow selector used by login/renew/status below.
|
|
40
|
+
const id = cfg.id ?? cfg.type;
|
|
38
41
|
return {
|
|
39
|
-
id
|
|
40
|
-
displayName: DISPLAY[cfg.type],
|
|
42
|
+
id,
|
|
43
|
+
displayName: cfg.id ? `${DISPLAY[cfg.type]} [${cfg.id}]` : DISPLAY[cfg.type],
|
|
44
|
+
default: cfg.default === true,
|
|
41
45
|
capabilities: { login: true, logout: true, status: true },
|
|
42
46
|
async login(ctx) {
|
|
43
47
|
if (cfg.type === "oauth2_authcode") {
|
|
@@ -54,7 +58,7 @@ export function oauth2Provider(cfg) {
|
|
|
54
58
|
});
|
|
55
59
|
}
|
|
56
60
|
const params = new URLSearchParams({ client_id: cfg.client_id });
|
|
57
|
-
const secret = await ctx.prompt({ label: "Client secret", secret: true });
|
|
61
|
+
const secret = cfg.client_secret ?? (await ctx.prompt({ label: "Client secret", secret: true }));
|
|
58
62
|
params.set("client_secret", secret);
|
|
59
63
|
if (cfg.type === "oauth2_password") {
|
|
60
64
|
const password = await ctx.prompt({ label: "Password", secret: true });
|
|
@@ -87,11 +91,11 @@ export function oauth2Provider(cfg) {
|
|
|
87
91
|
return info;
|
|
88
92
|
},
|
|
89
93
|
createAdapter(session, rt) {
|
|
90
|
-
// Mutable reference to the live session, updated in place after a
|
|
91
|
-
// so subsequent requests reuse the new token instead of
|
|
94
|
+
// Mutable reference to the live session, updated in place after a renewal
|
|
95
|
+
// so subsequent requests reuse the new token instead of renewing again.
|
|
92
96
|
let current = session;
|
|
93
|
-
let
|
|
94
|
-
async function
|
|
97
|
+
let inflight = null;
|
|
98
|
+
async function refreshAuthcode(refreshToken) {
|
|
95
99
|
const params = new URLSearchParams({
|
|
96
100
|
grant_type: "refresh_token",
|
|
97
101
|
refresh_token: refreshToken,
|
|
@@ -103,30 +107,63 @@ export function oauth2Provider(cfg) {
|
|
|
103
107
|
current = refreshed;
|
|
104
108
|
await rt.save(refreshed);
|
|
105
109
|
}
|
|
110
|
+
// client_credentials is idempotent: re-mint from client_id + secret, no
|
|
111
|
+
// refresh_token needed. The secret is config-provided and kept in memory
|
|
112
|
+
// only — never written to the persisted session (rt.save stores the token).
|
|
113
|
+
async function mintClientCredentials(secret) {
|
|
114
|
+
const params = new URLSearchParams({
|
|
115
|
+
grant_type: "client_credentials",
|
|
116
|
+
client_id: cfg.client_id,
|
|
117
|
+
client_secret: secret,
|
|
118
|
+
});
|
|
119
|
+
if (cfg.scope)
|
|
120
|
+
params.set("scope", cfg.scope);
|
|
121
|
+
const minted = await postToken(rt.http, cfg.token_url, params, rt.now);
|
|
122
|
+
current = minted;
|
|
123
|
+
await rt.save(minted);
|
|
124
|
+
}
|
|
125
|
+
async function renewSession() {
|
|
126
|
+
if (cfg.type === "oauth2_authcode") {
|
|
127
|
+
const refresh = current.refresh_token;
|
|
128
|
+
if (typeof refresh !== "string")
|
|
129
|
+
throw new AuthError("Session expired. Run 'dropsh auth login'.");
|
|
130
|
+
await refreshAuthcode(refresh);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (cfg.type === "oauth2_client_credentials" && cfg.client_secret) {
|
|
134
|
+
await mintClientCredentials(cfg.client_secret);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
throw new AuthError("Session expired. Run 'dropsh auth login'.");
|
|
138
|
+
}
|
|
139
|
+
// Coalesce concurrent renewals so we hit the token endpoint once.
|
|
140
|
+
function coalescedRenew() {
|
|
141
|
+
if (!inflight)
|
|
142
|
+
inflight = renewSession().finally(() => {
|
|
143
|
+
inflight = null;
|
|
144
|
+
});
|
|
145
|
+
return inflight;
|
|
146
|
+
}
|
|
106
147
|
return {
|
|
107
148
|
async apply(req) {
|
|
108
149
|
const token = current.access_token;
|
|
109
150
|
const expiresAt = typeof current.expires_at === "number" ? current.expires_at : 0;
|
|
110
151
|
if (typeof token === "string" && expiresAt - 30_000 > rt.now())
|
|
111
152
|
return { ...req, headers: bearer(req, token) };
|
|
112
|
-
|
|
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;
|
|
153
|
+
await coalescedRenew();
|
|
128
154
|
return { ...req, headers: bearer(req, current.access_token) };
|
|
129
155
|
},
|
|
156
|
+
// Reactive path: the server rejected the token (401). Try once to re-mint /
|
|
157
|
+
// refresh from the credentials we have; report whether the caller may retry.
|
|
158
|
+
async renew() {
|
|
159
|
+
try {
|
|
160
|
+
await coalescedRenew();
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
},
|
|
130
167
|
};
|
|
131
168
|
},
|
|
132
169
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dropsh/plugin-oauth2",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/plugins/oauth2/src/index.js",
|
|
6
6
|
"exports": {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"devDependencies": {
|
|
20
20
|
"typescript": "^5.6.0",
|
|
21
21
|
"vitest": "^4.1.8",
|
|
22
|
-
"dropsh": "0.
|
|
22
|
+
"dropsh": "0.3.1"
|
|
23
23
|
},
|
|
24
24
|
"scripts": {
|
|
25
25
|
"build": "tsc",
|