@dropsh/plugin-oauth2 0.5.8 → 0.6.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.
|
@@ -8,7 +8,30 @@ const DISPLAY = {
|
|
|
8
8
|
function bearer(req, token) {
|
|
9
9
|
return { ...(req.headers ?? {}), Authorization: `Bearer ${token}` };
|
|
10
10
|
}
|
|
11
|
-
|
|
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
|
+
async function postToken(http, url, params, now, identity) {
|
|
12
35
|
try {
|
|
13
36
|
const res = await http.send({
|
|
14
37
|
method: "POST",
|
|
@@ -29,8 +52,22 @@ async function postToken(http, url, params, now) {
|
|
|
29
52
|
catch (err) {
|
|
30
53
|
if (err instanceof AuthError)
|
|
31
54
|
throw err;
|
|
32
|
-
if (err instanceof HttpError)
|
|
33
|
-
|
|
55
|
+
if (err instanceof HttpError) {
|
|
56
|
+
// Interpret RFC 6749: only claim `invalid_client` when the server said so;
|
|
57
|
+
// everything else (a bare 401/403, another code, or no code) is the
|
|
58
|
+
// generic `credentials_rejected`. Name the inputs used, never the secret.
|
|
59
|
+
const oauthError = readOAuthError(err.body);
|
|
60
|
+
const reason = oauthError === "invalid_client" ? "invalid_client" : "credentials_rejected";
|
|
61
|
+
const scopePart = identity.scope !== undefined ? `, scope "${identity.scope}"` : "";
|
|
62
|
+
throw new AuthError(`OAuth2 token request rejected for profile "${identity.profile}" ` +
|
|
63
|
+
`(client_id "${identity.client_id}"${scopePart}) at ${identity.token_endpoint}: ${reason}.`, {
|
|
64
|
+
reason,
|
|
65
|
+
profile: identity.profile,
|
|
66
|
+
client_id: identity.client_id,
|
|
67
|
+
...(identity.scope !== undefined ? { scope: identity.scope } : {}),
|
|
68
|
+
token_endpoint: identity.token_endpoint,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
34
71
|
throw new AuthError(`Token request failed: ${err.message}`);
|
|
35
72
|
}
|
|
36
73
|
}
|
|
@@ -38,6 +75,33 @@ export function oauth2Provider(cfg) {
|
|
|
38
75
|
// Profile identity is `cfg.id` (falling back to `cfg.type`); `cfg.type` stays
|
|
39
76
|
// purely the grant-flow selector used by login/renew/status below.
|
|
40
77
|
const id = cfg.id ?? cfg.type;
|
|
78
|
+
// The non-secret identity every token request carries, so a rejection can name
|
|
79
|
+
// the inputs it used without ever echoing the client secret.
|
|
80
|
+
const identity = {
|
|
81
|
+
profile: id,
|
|
82
|
+
client_id: cfg.client_id,
|
|
83
|
+
...(cfg.scope !== undefined ? { scope: cfg.scope } : {}),
|
|
84
|
+
token_endpoint: cfg.token_url,
|
|
85
|
+
};
|
|
86
|
+
// A non-authcode grant needs a configured client_secret. An unset OR empty
|
|
87
|
+
// secret is a config error — not an expired session and not a prompt — so it
|
|
88
|
+
// fails BEFORE any token request (no empty secret is ever posted).
|
|
89
|
+
function requireClientSecret() {
|
|
90
|
+
const secret = cfg.type === "oauth2_client_credentials" || cfg.type === "oauth2_password"
|
|
91
|
+
? cfg.client_secret
|
|
92
|
+
: undefined;
|
|
93
|
+
if (typeof secret !== "string" || secret.length === 0) {
|
|
94
|
+
throw new AuthError(`OAuth2 profile "${id}" has no client_secret configured; ` +
|
|
95
|
+
`set plugins[].with.client_secret for this profile.`, {
|
|
96
|
+
reason: "secret_not_configured",
|
|
97
|
+
profile: id,
|
|
98
|
+
client_id: cfg.client_id,
|
|
99
|
+
...(cfg.scope !== undefined ? { scope: cfg.scope } : {}),
|
|
100
|
+
token_endpoint: cfg.token_url,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return secret;
|
|
104
|
+
}
|
|
41
105
|
return {
|
|
42
106
|
id,
|
|
43
107
|
displayName: cfg.id ? `${DISPLAY[cfg.type]} [${cfg.id}]` : DISPLAY[cfg.type],
|
|
@@ -57,10 +121,13 @@ export function oauth2Provider(cfg) {
|
|
|
57
121
|
now: ctx.now,
|
|
58
122
|
});
|
|
59
123
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
124
|
+
// The client_secret is config, not a runtime credential: require it up
|
|
125
|
+
// front (throws before any token request if unset/empty) rather than
|
|
126
|
+
// prompting for it or posting an empty secret.
|
|
127
|
+
const secret = requireClientSecret();
|
|
128
|
+
const params = new URLSearchParams({ client_id: cfg.client_id, client_secret: secret });
|
|
63
129
|
if (cfg.type === "oauth2_password") {
|
|
130
|
+
// The user's password *is* a runtime credential, so it is still prompted.
|
|
64
131
|
const password = await ctx.prompt({ label: "Password", secret: true });
|
|
65
132
|
params.set("grant_type", "password");
|
|
66
133
|
params.set("username", cfg.username);
|
|
@@ -71,7 +138,7 @@ export function oauth2Provider(cfg) {
|
|
|
71
138
|
}
|
|
72
139
|
if (cfg.scope)
|
|
73
140
|
params.set("scope", cfg.scope);
|
|
74
|
-
return postToken(ctx.http, cfg.token_url, params, ctx.now);
|
|
141
|
+
return postToken(ctx.http, cfg.token_url, params, ctx.now, identity);
|
|
75
142
|
},
|
|
76
143
|
async logout() {
|
|
77
144
|
// simple_oauth has no standard revoke endpoint wired; core clears the session.
|
|
@@ -101,7 +168,7 @@ export function oauth2Provider(cfg) {
|
|
|
101
168
|
refresh_token: refreshToken,
|
|
102
169
|
client_id: cfg.client_id,
|
|
103
170
|
});
|
|
104
|
-
const refreshed = await postToken(rt.http, cfg.token_url, params, rt.now);
|
|
171
|
+
const refreshed = await postToken(rt.http, cfg.token_url, params, rt.now, identity);
|
|
105
172
|
if (typeof refreshed.refresh_token !== "string")
|
|
106
173
|
refreshed.refresh_token = refreshToken;
|
|
107
174
|
current = refreshed;
|
|
@@ -118,7 +185,7 @@ export function oauth2Provider(cfg) {
|
|
|
118
185
|
});
|
|
119
186
|
if (cfg.scope)
|
|
120
187
|
params.set("scope", cfg.scope);
|
|
121
|
-
const minted = await postToken(rt.http, cfg.token_url, params, rt.now);
|
|
188
|
+
const minted = await postToken(rt.http, cfg.token_url, params, rt.now, identity);
|
|
122
189
|
current = minted;
|
|
123
190
|
await rt.save(minted);
|
|
124
191
|
}
|
|
@@ -130,10 +197,14 @@ export function oauth2Provider(cfg) {
|
|
|
130
197
|
await refreshAuthcode(refresh);
|
|
131
198
|
return;
|
|
132
199
|
}
|
|
133
|
-
if (cfg.type === "oauth2_client_credentials"
|
|
134
|
-
|
|
200
|
+
if (cfg.type === "oauth2_client_credentials") {
|
|
201
|
+
// A missing/empty secret here is a config error (surfaced by the
|
|
202
|
+
// guard), not a genuinely expired session.
|
|
203
|
+
await mintClientCredentials(requireClientSecret());
|
|
135
204
|
return;
|
|
136
205
|
}
|
|
206
|
+
// password grant: no silent re-mint is possible (it needs the user's
|
|
207
|
+
// password), so a genuinely expired session must be re-established by login.
|
|
137
208
|
throw new AuthError("Session expired. Run 'dropsh auth login'.");
|
|
138
209
|
}
|
|
139
210
|
// Coalesce concurrent renewals so we hit the token endpoint once.
|
|
@@ -154,14 +225,15 @@ export function oauth2Provider(cfg) {
|
|
|
154
225
|
return { ...req, headers: bearer(req, current.access_token) };
|
|
155
226
|
},
|
|
156
227
|
// 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
|
|
228
|
+
// refresh from the credentials we have; report whether the caller may retry
|
|
229
|
+
// and, on failure, carry the cause so the caller can surface *why*.
|
|
158
230
|
async renew() {
|
|
159
231
|
try {
|
|
160
232
|
await coalescedRenew();
|
|
161
|
-
return true;
|
|
233
|
+
return { ok: true };
|
|
162
234
|
}
|
|
163
|
-
catch {
|
|
164
|
-
return false;
|
|
235
|
+
catch (cause) {
|
|
236
|
+
return { ok: false, cause };
|
|
165
237
|
}
|
|
166
238
|
},
|
|
167
239
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dropsh/plugin-oauth2",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
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.
|
|
26
|
+
"dropsh": "0.6.1"
|
|
27
27
|
},
|
|
28
28
|
"scripts": {
|
|
29
29
|
"build": "tsc",
|