@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
- async function postToken(http, url, params, now) {
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
- throw new AuthError(`Token request failed: HTTP ${err.status}`, { body: err.body });
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
- const params = new URLSearchParams({ client_id: cfg.client_id });
61
- const secret = cfg.client_secret ?? (await ctx.prompt({ label: "Client secret", secret: true }));
62
- params.set("client_secret", secret);
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" && cfg.client_secret) {
134
- await mintClientCredentials(cfg.client_secret);
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.5.8",
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.5.8"
26
+ "dropsh": "0.6.1"
27
27
  },
28
28
  "scripts": {
29
29
  "build": "tsc",