@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.
- package/cli.js +182 -24
- package/package.json +1 -1
- 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
|
|
116
|
-
if (!config.
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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
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
|
# フォルダ一覧 (環境ごとにグループ化)
|