@yhonda/gcloud-secrets 3.0.0 → 3.1.0

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.
Files changed (3) hide show
  1. package/cli.js +182 -24
  2. package/package.json +1 -1
  3. package/skills/secrets.md +20 -0
package/cli.js CHANGED
@@ -46,6 +46,8 @@ function getConfig() {
46
46
  ageKeyPath: process.env.AGE_KEY_PATH || join(homedir(), ".age", "key.txt"),
47
47
  googleClientId: process.env.GOOGLE_CLIENT_ID || "",
48
48
  googleClientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
49
+ googleDeviceClientId: process.env.GOOGLE_DEVICE_CLIENT_ID || "",
50
+ googleDeviceClientSecret: process.env.GOOGLE_DEVICE_CLIENT_SECRET || "",
49
51
  };
50
52
  if (existsSync(configFile)) {
51
53
  const content = readFileSync(configFile, "utf-8");
@@ -56,6 +58,8 @@ function getConfig() {
56
58
  ['AGE_KEY_PATH', 'ageKeyPath'],
57
59
  ['GOOGLE_CLIENT_ID', 'googleClientId'],
58
60
  ['GOOGLE_CLIENT_SECRET', 'googleClientSecret'],
61
+ ['GOOGLE_DEVICE_CLIENT_ID', 'googleDeviceClientId'],
62
+ ['GOOGLE_DEVICE_CLIENT_SECRET', 'googleDeviceClientSecret'],
59
63
  ]) {
60
64
  const match = content.match(new RegExp(`^${envKey}=(.+)$`, 'm'));
61
65
  if (match) config[configKey] = match[1].trim();
@@ -107,38 +111,86 @@ function getAgePublicKeyFromFile(keyPath) {
107
111
  const OAUTH_REDIRECT_PORT = 3456;
108
112
  const OAUTH_REDIRECT_URI = `http://localhost:${OAUTH_REDIRECT_PORT}/callback`;
109
113
  const OAUTH_SCOPES = ['https://www.googleapis.com/auth/drive'];
114
+ const DEVICE_CODE_URL = 'https://oauth2.googleapis.com/device/code';
115
+ const TOKEN_URL = 'https://oauth2.googleapis.com/token';
116
+ const DEVICE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
110
117
 
111
118
  function getTokenPath() {
112
119
  return join(homedir(), '.secrets-manager-oauth.json');
113
120
  }
114
121
 
115
- async function getAuthClient(config) {
116
- if (!config.googleClientId || !config.googleClientSecret) {
117
- console.error('エラー: Google OAuth クライアント ID/Secret が設定されていません');
118
- console.error('init コマンドで --client-id --client-secret を指定してください');
119
- process.exit(1);
122
+ async function performDeviceFlow(config) {
123
+ if (!config.googleDeviceClientId || !config.googleDeviceClientSecret) {
124
+ throw new Error(
125
+ 'Device flow OAuth client が未設定です。\n' +
126
+ '~/.secrets-manager.conf に GOOGLE_DEVICE_CLIENT_ID / GOOGLE_DEVICE_CLIENT_SECRET を設定してください\n' +
127
+ '(Google Cloud Console → OAuth 2.0 クライアント ID → "TVs and Limited Input devices" タイプ)'
128
+ );
120
129
  }
121
130
 
122
- const oauth2Client = new google.auth.OAuth2(
123
- config.googleClientId,
124
- config.googleClientSecret,
125
- OAUTH_REDIRECT_URI
126
- );
127
-
128
- const tokenPath = getTokenPath();
129
- if (existsSync(tokenPath)) {
130
- const tokens = JSON.parse(readFileSync(tokenPath, 'utf-8'));
131
- oauth2Client.setCredentials(tokens);
132
- oauth2Client.on('tokens', (newTokens) => {
133
- try {
134
- const saved = JSON.parse(readFileSync(tokenPath, 'utf-8'));
135
- writeFileSync(tokenPath, JSON.stringify({ ...saved, ...newTokens }, null, 2));
136
- } catch { }
131
+ // Step 1: Device code 取得
132
+ const deviceRes = await fetch(DEVICE_CODE_URL, {
133
+ method: 'POST',
134
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
135
+ body: new URLSearchParams({
136
+ client_id: config.googleDeviceClientId,
137
+ scope: OAUTH_SCOPES.join(' '),
138
+ }),
139
+ });
140
+ if (!deviceRes.ok) {
141
+ const text = await deviceRes.text();
142
+ throw new Error(`Device code リクエスト失敗 (${deviceRes.status}): ${text}`);
143
+ }
144
+ const dc = await deviceRes.json();
145
+ // dc: { device_code, user_code, verification_url, expires_in, interval }
146
+
147
+ // Step 2: ユーザーへ案内
148
+ console.log('\n認証するには、以下の URL を別デバイスのブラウザで開き、コードを入力してください:');
149
+ console.log(` URL: ${dc.verification_url}`);
150
+ console.log(` Code: ${dc.user_code}`);
151
+ console.log(` (有効期限 ${Math.floor(dc.expires_in / 60)} 分、待機中...)\n`);
152
+
153
+ // Step 3: Poll
154
+ const pollInterval = Math.max(dc.interval || 5, 1);
155
+ const deadline = Date.now() + dc.expires_in * 1000;
156
+ let currentInterval = pollInterval;
157
+ while (Date.now() < deadline) {
158
+ await new Promise((r) => setTimeout(r, currentInterval * 1000));
159
+ const tokenRes = await fetch(TOKEN_URL, {
160
+ method: 'POST',
161
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
162
+ body: new URLSearchParams({
163
+ client_id: config.googleDeviceClientId,
164
+ client_secret: config.googleDeviceClientSecret,
165
+ device_code: dc.device_code,
166
+ grant_type: DEVICE_GRANT_TYPE,
167
+ }),
137
168
  });
138
- return oauth2Client;
169
+ const data = await tokenRes.json();
170
+ if (data.error === 'authorization_pending') continue;
171
+ if (data.error === 'slow_down') { currentInterval += 5; continue; }
172
+ if (data.error === 'access_denied') throw new Error('ユーザーが認証を拒否しました');
173
+ if (data.error === 'expired_token') throw new Error('Device code が失効しました、再実行してください');
174
+ if (data.error) throw new Error(`Device flow エラー: ${data.error} ${data.error_description || ''}`);
175
+ if (data.access_token) {
176
+ const tokenPath = getTokenPath();
177
+ // _client_type マーカーで、後続の refresh 時にどの client で発行されたか判別する
178
+ const annotated = { ...data, _client_type: 'device' };
179
+ writeFileSync(tokenPath, JSON.stringify(annotated, null, 2));
180
+ console.log('認証完了');
181
+ return annotated;
182
+ }
139
183
  }
184
+ throw new Error('Device flow タイムアウト');
185
+ }
186
+
187
+ function isInvalidGrantError(err) {
188
+ const msg = String(err?.message || '');
189
+ const data = err?.response?.data || {};
190
+ return msg.includes('invalid_grant') || data.error === 'invalid_grant';
191
+ }
140
192
 
141
- // 初回認証フロー
193
+ async function performOAuthFlow(oauth2Client) {
142
194
  const authUrl = oauth2Client.generateAuthUrl({
143
195
  access_type: 'offline',
144
196
  scope: OAUTH_SCOPES,
@@ -146,11 +198,17 @@ async function getAuthClient(config) {
146
198
  });
147
199
 
148
200
  console.log('ブラウザで認証を行います...');
201
+ let browserOpened = false;
149
202
  try {
150
203
  const openCmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
151
204
  execSync(`${openCmd} "${authUrl}"`, { stdio: 'ignore' });
152
- } catch {
153
- console.log(`以下のURLをブラウザで開いてください:\n${authUrl}`);
205
+ browserOpened = true;
206
+ } catch { }
207
+ if (!browserOpened) {
208
+ console.log(`ブラウザが開けませんでした。以下のURLを手動で開いてください:\n${authUrl}`);
209
+ } else {
210
+ // リモートセッションなど stdio 共有環境用に URL も常時表示
211
+ console.log(`(ブラウザが開かない場合は以下を開いてください)\n${authUrl}`);
154
212
  }
155
213
 
156
214
  const code = await new Promise((resolve, reject) => {
@@ -177,6 +235,7 @@ async function getAuthClient(config) {
177
235
 
178
236
  const { tokens } = await oauth2Client.getToken(code);
179
237
  oauth2Client.setCredentials(tokens);
238
+ const tokenPath = getTokenPath();
180
239
  writeFileSync(tokenPath, JSON.stringify(tokens, null, 2));
181
240
  console.log('認証完了');
182
241
 
@@ -190,6 +249,43 @@ async function getAuthClient(config) {
190
249
  return oauth2Client;
191
250
  }
192
251
 
252
+ async function getAuthClient(config) {
253
+ const tokenPath = getTokenPath();
254
+ // 既存 token があれば、発行元 client (desktop / device) を判別して対応する credential を使う
255
+ if (existsSync(tokenPath)) {
256
+ const tokens = JSON.parse(readFileSync(tokenPath, 'utf-8'));
257
+ const isDeviceToken = tokens._client_type === 'device';
258
+ const clientId = isDeviceToken ? config.googleDeviceClientId : config.googleClientId;
259
+ const clientSecret = isDeviceToken ? config.googleDeviceClientSecret : config.googleClientSecret;
260
+ if (!clientId || !clientSecret) {
261
+ console.error(`エラー: token (${isDeviceToken ? 'device' : 'desktop'}) 発行元の OAuth client 情報が未設定です`);
262
+ process.exit(1);
263
+ }
264
+ const oauth2Client = new google.auth.OAuth2(clientId, clientSecret, OAUTH_REDIRECT_URI);
265
+ oauth2Client.setCredentials(tokens);
266
+ oauth2Client.on('tokens', (newTokens) => {
267
+ try {
268
+ const saved = JSON.parse(readFileSync(tokenPath, 'utf-8'));
269
+ writeFileSync(tokenPath, JSON.stringify({ ...saved, ...newTokens }, null, 2));
270
+ } catch { }
271
+ });
272
+ return oauth2Client;
273
+ }
274
+
275
+ // token 無し → 初回 desktop OAuth (init 用)
276
+ if (!config.googleClientId || !config.googleClientSecret) {
277
+ console.error('エラー: Google OAuth クライアント ID/Secret が設定されていません');
278
+ console.error('init コマンドで --client-id と --client-secret を指定してください');
279
+ process.exit(1);
280
+ }
281
+ const oauth2Client = new google.auth.OAuth2(
282
+ config.googleClientId,
283
+ config.googleClientSecret,
284
+ OAUTH_REDIRECT_URI
285
+ );
286
+ return performOAuthFlow(oauth2Client);
287
+ }
288
+
193
289
  async function getDriveClient(config) {
194
290
  const auth = await getAuthClient(config);
195
291
  return google.drive({ version: 'v3', auth });
@@ -501,6 +597,55 @@ async function runCli(args) {
501
597
  break;
502
598
  }
503
599
 
600
+ case "reauth": {
601
+ // init 済み前提
602
+ if (!config.driveFolderId) {
603
+ console.error('エラー: DRIVE_FOLDER_ID が未設定です');
604
+ console.error('先に init を実行してください');
605
+ process.exit(1);
606
+ }
607
+ if (!config.googleDeviceClientId || !config.googleDeviceClientSecret) {
608
+ console.error('エラー: Device flow 用 OAuth client が未設定です');
609
+ console.error('~/.secrets-manager.conf に GOOGLE_DEVICE_CLIENT_ID / GOOGLE_DEVICE_CLIENT_SECRET を追加してください');
610
+ console.error('(Google Cloud Console → OAuth 2.0 クライアント ID → "TVs and Limited Input devices" タイプを作成)');
611
+ process.exit(1);
612
+ }
613
+
614
+ // 失効 token を退避
615
+ const tokenPath = getTokenPath();
616
+ if (existsSync(tokenPath)) {
617
+ const backupPath = `${tokenPath}.stale-${Date.now()}`;
618
+ try {
619
+ writeFileSync(backupPath, readFileSync(tokenPath));
620
+ rmSync(tokenPath);
621
+ console.log(`旧 token を退避: ${backupPath}`);
622
+ } catch (e) {
623
+ console.error(`警告: 旧 token の退避に失敗: ${e.message}`);
624
+ }
625
+ }
626
+
627
+ // Device Flow 実行 (別デバイスのブラウザで承認)
628
+ const tokens = await performDeviceFlow(config);
629
+
630
+ // googleapis の OAuth2 クライアントにトークンを流し込んで Drive 疎通確認
631
+ const oauth2Client = new google.auth.OAuth2(
632
+ config.googleDeviceClientId,
633
+ config.googleDeviceClientSecret
634
+ );
635
+ oauth2Client.setCredentials(tokens);
636
+ const drive = google.drive({ version: 'v3', auth: oauth2Client });
637
+ try {
638
+ const res = await drive.files.get({ fileId: config.driveFolderId, fields: 'id, name' });
639
+ console.log(`Drive フォルダ確認: ${res.data.name} (${config.driveFolderId})`);
640
+ } catch (e) {
641
+ console.error(`警告: Drive フォルダ検証失敗 (${e.message})`);
642
+ console.error('token は更新済み。フォルダ ID / 権限を確認してください');
643
+ process.exit(1);
644
+ }
645
+ console.log('再認証完了');
646
+ break;
647
+ }
648
+
504
649
  case "list": {
505
650
  const folder = parsed.positional[1];
506
651
  const drive = await getDriveClient(config);
@@ -733,7 +878,15 @@ async function runCli(args) {
733
878
  let drive;
734
879
  try { drive = await getDriveClient(config); } catch { process.exit(0); }
735
880
 
881
+ let reauthWarned = false;
882
+ const warnReauth = () => {
883
+ if (reauthWarned) return;
884
+ reauthWarned = true;
885
+ console.error('⚠ OAuth expired. Run `gcloud-secrets reauth` to re-authenticate.');
886
+ };
887
+
736
888
  for (const envFile of envFiles) {
889
+ if (reauthWarned) break;
737
890
  let content;
738
891
  try { content = readFileSync(envFile.path, 'utf-8'); } catch { continue; }
739
892
  if (!content.trim()) continue;
@@ -797,6 +950,10 @@ async function runCli(args) {
797
950
  syncedAt: new Date().toISOString()
798
951
  };
799
952
  } catch (error) {
953
+ if (isInvalidGrantError(error)) {
954
+ warnReauth();
955
+ break;
956
+ }
800
957
  console.log(`⚠ ${envFile.filename}: sync skipped (${error.message})`);
801
958
  }
802
959
  }
@@ -1006,6 +1163,7 @@ exit 0
1006
1163
  使い方:
1007
1164
  gcloud-secrets init [drive-folder-id] --client-id <id> --client-secret <secret> [--env <default>]
1008
1165
  初期設定 (OAuth + age 鍵 + Drive フォルダ)
1166
+ gcloud-secrets reauth OAuth token 再認証のみ (config は保持)
1009
1167
  gcloud-secrets list [folder] [--env <env>] 一覧表示
1010
1168
  gcloud-secrets pull [folder] [--env <env>] シークレットを取得
1011
1169
  gcloud-secrets push [folder] [file] [--env <env>] シークレットをアップロード
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yhonda/gcloud-secrets",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "Google Drive + age 暗号化でシークレットを管理する CLI ツール",
5
5
  "type": "module",
6
6
  "main": "cli.js",
package/skills/secrets.md CHANGED
@@ -15,6 +15,26 @@ Google Drive + OAuth + age 鍵の初期設定を行います。
15
15
  - `--age-key <path>` で age 秘密鍵パスを指定(省略時は `~/.age/key.txt`、未作成なら自動生成)
16
16
  - `--age-pub <key>` で age 公開鍵を指定(省略時は秘密鍵ファイルから自動取得)
17
17
 
18
+ ### 再認証 (reauth)
19
+ ```bash
20
+ gcloud-secrets reauth
21
+ ```
22
+ OAuth token が失効した (refresh token invalid_grant) 時に、**token だけ** を更新します。
23
+ - 既存 config (DRIVE_FOLDER_ID / OAuth client / age 鍵) には一切触れない
24
+ - 失効 token は `~/.secrets-manager-oauth.json.stale-<timestamp>` に退避
25
+ - **OAuth 2.0 Device Flow** (Tailscale 風) で認証: URL + ユーザーコード表示 → 別デバイスで承認 → CLI は token エンドポイントを poll
26
+ - リモート SSH / ヘッドレス環境でも動作 (ローカルブラウザ不要)
27
+ - OAuth フロー後に Drive フォルダの read 疎通も確認
28
+ - pre-commit hook が `invalid_grant` を検知すると `reauth` の実行を促すメッセージを表示 (commit は blocking しない)
29
+
30
+ **前提**: `~/.secrets-manager.conf` に以下を追加しておくこと (Google Cloud Console で "TVs and Limited Input devices" タイプの OAuth client を作成):
31
+ ```
32
+ GOOGLE_DEVICE_CLIENT_ID=xxxxx.apps.googleusercontent.com
33
+ GOOGLE_DEVICE_CLIENT_SECRET=GOCSPX-xxxxx
34
+ ```
35
+
36
+ Init の desktop flow で取った token と device flow で取った token は `_client_type` マーカーで区別され、自動で適切な client 情報で refresh されます。
37
+
18
38
  ### 一覧表示
19
39
  ```bash
20
40
  # フォルダ一覧 (環境ごとにグループ化)