@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 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 | 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 |
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` are **prompted at login**, never stored
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: cfg.type,
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 refresh
91
- // so subsequent requests reuse the new token instead of re-refreshing.
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 refreshing = null;
94
- async function doRefresh(refreshToken) {
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
- // 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;
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.2.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.2.0"
22
+ "dropsh": "0.3.1"
23
23
  },
24
24
  "scripts": {
25
25
  "build": "tsc",