@athsra/cli 0.1.0 → 1.0.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/package.json +9 -4
- package/src/commands/admin.ts +308 -0
- package/src/commands/adopt.ts +490 -0
- package/src/commands/audit.ts +156 -0
- package/src/commands/completion.ts +101 -0
- package/src/commands/delete.ts +1 -1
- package/src/commands/doctor.ts +86 -2
- package/src/commands/get.ts +10 -14
- package/src/commands/handoff.ts +20 -16
- package/src/commands/login.ts +306 -1
- package/src/commands/logout.ts +50 -0
- package/src/commands/ls.ts +24 -14
- package/src/commands/manifest.ts +354 -0
- package/src/commands/mcp.ts +595 -0
- package/src/commands/migrate-envelopes.ts +113 -0
- package/src/commands/purge.ts +1 -1
- package/src/commands/restore.ts +1 -1
- package/src/commands/revoke.ts +10 -7
- package/src/commands/rollback.ts +1 -1
- package/src/commands/rotate-master.ts +42 -48
- package/src/commands/run.ts +49 -16
- package/src/commands/service-token.ts +200 -0
- package/src/commands/set.ts +33 -47
- package/src/commands/unset.ts +9 -38
- package/src/commands/versions.ts +10 -4
- package/src/index.ts +79 -4
- package/src/lib/adopt-context.ts +183 -0
- package/src/lib/auth-context.ts +77 -4
- package/src/lib/auto-project.ts +131 -0
- package/src/lib/client.ts +359 -4
- package/src/lib/env-format.ts +25 -0
- package/src/lib/envelope.ts +76 -0
- package/src/lib/secrets-manifest.ts +309 -0
- package/src/lib/workers-builds.ts +274 -0
- package/src/lib/wrangler-sync.ts +202 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* completion.ts — shell completion script 출력 (bash/zsh/fish).
|
|
3
|
+
*
|
|
4
|
+
* 사용:
|
|
5
|
+
* athsra completion zsh >> ~/.zshrc
|
|
6
|
+
* athsra completion bash >> ~/.bashrc
|
|
7
|
+
* athsra completion fish > ~/.config/fish/completions/athsra.fish
|
|
8
|
+
*
|
|
9
|
+
* 또는 1회용 source:
|
|
10
|
+
* source <(athsra completion zsh)
|
|
11
|
+
*
|
|
12
|
+
* Phase 1.x.7 — Doppler 의 `doppler completion zsh|bash|fish` 동등 UX.
|
|
13
|
+
* 19 commands subcommand 기본 complete. sub-options (--from-file 등) 은 별 chunk.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
interface CommandSpec {
|
|
17
|
+
name: string;
|
|
18
|
+
desc: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const COMMANDS: CommandSpec[] = [
|
|
22
|
+
{ name: 'login', desc: 'master pw 입력 + 머신 등록 (Bearer token 발급)' },
|
|
23
|
+
{ name: 'logout', desc: 'keyring clear (worker token 유지). --full 시 config 도 삭제' },
|
|
24
|
+
{ name: 'init', desc: '신규 project 안내 (실 생성은 첫 set 시)' },
|
|
25
|
+
{ name: 'set', desc: 'secret 추가/수정 (다건, --from-file, --stdin 지원)' },
|
|
26
|
+
{ name: 'unset', desc: '특정 key 제거 (envelope 유지)' },
|
|
27
|
+
{ name: 'get', desc: '값 출력 (single 또는 dump)' },
|
|
28
|
+
{ name: 'ls', desc: 'project 또는 key 목록 (--all=deleted 포함)' },
|
|
29
|
+
{ name: 'run', desc: 'env inject 후 명령 실행 (Doppler-style)' },
|
|
30
|
+
{ name: 'doctor', desc: '환경 검증 (keyring/dbus/worker phase)' },
|
|
31
|
+
{ name: 'versions', desc: '모든 version + tombstone 상태' },
|
|
32
|
+
{ name: 'rollback', desc: '특정 version 으로 current 복원' },
|
|
33
|
+
{ name: 'delete', desc: 'soft-delete (default) 또는 --hard' },
|
|
34
|
+
{ name: 'restore', desc: 'tombstone 제거 + 최신 version 활성화' },
|
|
35
|
+
{ name: 'purge', desc: 'hard-delete 별칭 (double-confirm)' },
|
|
36
|
+
{ name: 'rotate-master', desc: 'master pw 변경 (모든 projects re-encrypt)' },
|
|
37
|
+
{ name: 'new-phrase', desc: 'BIP-39 12-word phrase 생성' },
|
|
38
|
+
{ name: 'handoff', desc: '새 머신 추가 (issue / --accept)' },
|
|
39
|
+
{ name: 'revoke', desc: 'self 또는 명시 token revoke' },
|
|
40
|
+
{ name: 'completion', desc: 'shell completion script 출력' },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
function bashCompletion(): string {
|
|
44
|
+
const names = COMMANDS.map((c) => c.name).join(' ');
|
|
45
|
+
return `# athsra bash completion — generated by 'athsra completion bash'
|
|
46
|
+
_athsra_complete() {
|
|
47
|
+
local cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
48
|
+
if [ "$COMP_CWORD" -eq 1 ]; then
|
|
49
|
+
COMPREPLY=( $(compgen -W "${names} --version --help" -- "$cur") )
|
|
50
|
+
fi
|
|
51
|
+
}
|
|
52
|
+
complete -F _athsra_complete athsra
|
|
53
|
+
`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function zshCompletion(): string {
|
|
57
|
+
const lines = COMMANDS.map((c) => ` '${c.name}:${c.desc.replace(/'/g, "''")}'`).join('\n');
|
|
58
|
+
return `#compdef athsra
|
|
59
|
+
# athsra zsh completion — generated by 'athsra completion zsh'
|
|
60
|
+
_athsra() {
|
|
61
|
+
local -a _athsra_cmds
|
|
62
|
+
_athsra_cmds=(
|
|
63
|
+
${lines}
|
|
64
|
+
'--version:print version'
|
|
65
|
+
'--help:print help'
|
|
66
|
+
)
|
|
67
|
+
if (( CURRENT == 2 )); then
|
|
68
|
+
_describe 'command' _athsra_cmds
|
|
69
|
+
fi
|
|
70
|
+
}
|
|
71
|
+
compdef _athsra athsra
|
|
72
|
+
`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function fishCompletion(): string {
|
|
76
|
+
return COMMANDS.map(
|
|
77
|
+
(c) =>
|
|
78
|
+
`complete -c athsra -n "__fish_use_subcommand" -a "${c.name}" -d "${c.desc.replace(/"/g, '\\"')}"`,
|
|
79
|
+
)
|
|
80
|
+
.join('\n')
|
|
81
|
+
.concat('\n');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function completionCmd(args: string[]): Promise<number> {
|
|
85
|
+
const shell = args[0];
|
|
86
|
+
if (!shell || (shell !== 'bash' && shell !== 'zsh' && shell !== 'fish')) {
|
|
87
|
+
console.error('usage: athsra completion {bash|zsh|fish}');
|
|
88
|
+
console.error('');
|
|
89
|
+
console.error(' Append to your shell rc:');
|
|
90
|
+
console.error(' bash: athsra completion bash >> ~/.bashrc');
|
|
91
|
+
console.error(
|
|
92
|
+
' zsh: athsra completion zsh >> ~/.zshrc (또는 ~/.zsh/completions/_athsra)',
|
|
93
|
+
);
|
|
94
|
+
console.error(' fish: athsra completion fish > ~/.config/fish/completions/athsra.fish');
|
|
95
|
+
return 2;
|
|
96
|
+
}
|
|
97
|
+
if (shell === 'bash') console.log(bashCompletion());
|
|
98
|
+
else if (shell === 'zsh') console.log(zshCompletion());
|
|
99
|
+
else console.log(fishCompletion());
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
package/src/commands/delete.ts
CHANGED
|
@@ -22,7 +22,7 @@ export async function deleteCmd(args: string[]): Promise<number> {
|
|
|
22
22
|
const hard = args.includes('--hard');
|
|
23
23
|
const yes = args.includes('--yes') || args.includes('-y');
|
|
24
24
|
|
|
25
|
-
const ctx = loadAuthContext();
|
|
25
|
+
const ctx = await loadAuthContext();
|
|
26
26
|
if (!ctx) return 1;
|
|
27
27
|
const { client } = ctx;
|
|
28
28
|
|
package/src/commands/doctor.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
|
+
import type { UserAuthContext } from '../lib/auth-context.ts';
|
|
2
3
|
import { AthsraClient } from '../lib/client.ts';
|
|
3
4
|
import { CONFIG_FILE, loadConfig, SESSION_FILE } from '../lib/config.ts';
|
|
5
|
+
import { partitionEnv } from '../lib/env-format.ts';
|
|
6
|
+
import { readPlain } from '../lib/envelope.ts';
|
|
4
7
|
import { getMasterPw, getToken, probeKeyring } from '../lib/keyring.ts';
|
|
5
8
|
|
|
6
|
-
export async function doctorCmd(
|
|
9
|
+
export async function doctorCmd(args: string[]): Promise<number> {
|
|
10
|
+
const audit = args.includes('--audit');
|
|
7
11
|
console.log('athsra doctor\n');
|
|
8
12
|
|
|
9
13
|
const kr = probeKeyring();
|
|
@@ -49,6 +53,15 @@ export async function doctorCmd(_args: string[]): Promise<number> {
|
|
|
49
53
|
if (info.phase < 1) {
|
|
50
54
|
console.log(' ⚠ phase < 1 — Pre-A 인증 미배포 상태.');
|
|
51
55
|
}
|
|
56
|
+
// Phase 1.x.5: GLOBAL_SALT_VERSION change 감지
|
|
57
|
+
if (info.proof_invalidated) {
|
|
58
|
+
console.log(
|
|
59
|
+
` ⚠ PROOF invalidated — GLOBAL_SALT_VERSION 변경 감지 (PROOF: ${info.proof_global_salt_version} → worker: ${info.global_salt_version}).`,
|
|
60
|
+
);
|
|
61
|
+
console.log(' 다음 `athsra login` 시 자동 재 register. R2 envelope 보존.');
|
|
62
|
+
} else if (info.proof_global_salt_version) {
|
|
63
|
+
console.log(` proof_global_salt_version: ${info.proof_global_salt_version} (정상 일치)`);
|
|
64
|
+
}
|
|
52
65
|
} catch (err) {
|
|
53
66
|
console.log(` info: error — ${(err as Error).message}`);
|
|
54
67
|
}
|
|
@@ -72,5 +85,76 @@ export async function doctorCmd(_args: string[]): Promise<number> {
|
|
|
72
85
|
}
|
|
73
86
|
}
|
|
74
87
|
|
|
75
|
-
|
|
88
|
+
let auditFailed = false;
|
|
89
|
+
if (audit) {
|
|
90
|
+
if (!masterPw) {
|
|
91
|
+
console.log('\n secret audit: ✗ skipped — master-pw missing (run `athsra login`)');
|
|
92
|
+
} else if (!token) {
|
|
93
|
+
console.log('\n secret audit: ✗ skipped — token missing (run `athsra login`)');
|
|
94
|
+
} else {
|
|
95
|
+
const ctx: UserAuthContext = { kind: 'user', config, masterPw, token, client };
|
|
96
|
+
auditFailed = await runSecretAudit(ctx);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return kr.ok && reachable && !auditFailed ? 0 : 1;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* `athsra doctor --audit` — 전 active project 를 decrypt 해 빈 값 키를 전수 점검한다.
|
|
105
|
+
*
|
|
106
|
+
* Issue 3: 2026-05-10 secret migration 이 키 이름만 scaffolding 하고 값 입력을
|
|
107
|
+
* 누락해 여러 project 가 빈 값 키를 갖게 됐다 (ecosystem.json 의 keysCount=0
|
|
108
|
+
* 기록과 불일치). worker 는 E2EE 라 plaintext 를 못 보므로 점검은 client-side.
|
|
109
|
+
*
|
|
110
|
+
* @returns true = 빈 값 키 또는 점검 오류 발견 (doctor exit code 에 반영)
|
|
111
|
+
*/
|
|
112
|
+
async function runSecretAudit(ctx: UserAuthContext): Promise<boolean> {
|
|
113
|
+
console.log('\n secret audit (empty-value keys):');
|
|
114
|
+
let projects: string[];
|
|
115
|
+
try {
|
|
116
|
+
projects = await ctx.client.listProjects();
|
|
117
|
+
} catch (err) {
|
|
118
|
+
console.log(` ✗ project list error — ${(err as Error).message}`);
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
if (projects.length === 0) {
|
|
122
|
+
console.log(' (no projects)');
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let affected = 0;
|
|
127
|
+
let errored = 0;
|
|
128
|
+
for (const project of [...projects].sort()) {
|
|
129
|
+
try {
|
|
130
|
+
const plain = await readPlain(ctx, project);
|
|
131
|
+
if (!plain) {
|
|
132
|
+
console.log(` ✗ ${project}: envelope missing`);
|
|
133
|
+
errored++;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const total = Object.keys(plain).length;
|
|
137
|
+
const { emptyKeys } = partitionEnv(plain);
|
|
138
|
+
if (emptyKeys.length > 0) {
|
|
139
|
+
affected++;
|
|
140
|
+
console.log(
|
|
141
|
+
` ✗ ${project}: ${emptyKeys.length}/${total} empty — ${emptyKeys.join(', ')}`,
|
|
142
|
+
);
|
|
143
|
+
} else {
|
|
144
|
+
console.log(` ✓ ${project}: ${total} key${total === 1 ? '' : 's'}, all set`);
|
|
145
|
+
}
|
|
146
|
+
} catch (err) {
|
|
147
|
+
errored++;
|
|
148
|
+
console.log(` ✗ ${project}: decrypt error — ${(err as Error).message}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (affected > 0) {
|
|
153
|
+
console.log(
|
|
154
|
+
`\n ⚠ ${affected} project${affected === 1 ? '' : 's'} with empty-value keys — deploy auth fails until real values are set (\`athsra set <project> KEY=value\`).`,
|
|
155
|
+
);
|
|
156
|
+
} else if (errored === 0) {
|
|
157
|
+
console.log('\n ✓ all projects: every key has a non-empty value.');
|
|
158
|
+
}
|
|
159
|
+
return affected > 0 || errored > 0;
|
|
76
160
|
}
|
package/src/commands/get.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { decrypt, deriveKey, fromBase64 } from '@athsra/crypto';
|
|
2
1
|
import { loadAuthContext } from '../lib/auth-context.ts';
|
|
3
|
-
import {
|
|
2
|
+
import { serializeEnv } from '../lib/env-format.ts';
|
|
3
|
+
import { readPlain } from '../lib/envelope.ts';
|
|
4
4
|
|
|
5
5
|
export async function getCmd(args: string[]): Promise<number> {
|
|
6
6
|
const project = args[0];
|
|
@@ -10,31 +10,27 @@ export async function getCmd(args: string[]): Promise<number> {
|
|
|
10
10
|
return 2;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
const ctx = loadAuthContext();
|
|
13
|
+
const ctx = await loadAuthContext();
|
|
14
14
|
if (!ctx) return 1;
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
if (!
|
|
15
|
+
|
|
16
|
+
const plain = await readPlain(ctx, project);
|
|
17
|
+
if (!plain) {
|
|
18
18
|
console.error(`project not found: ${project}`);
|
|
19
19
|
return 1;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
const derived = deriveKey(masterPw, fromBase64(envelope.salt));
|
|
23
|
-
const text = await decrypt(derived, {
|
|
24
|
-
ciphertext: fromBase64(envelope.ciphertext),
|
|
25
|
-
nonce: fromBase64(envelope.nonce),
|
|
26
|
-
});
|
|
27
|
-
|
|
28
22
|
if (key) {
|
|
29
|
-
const plain = parseEnv(text);
|
|
30
23
|
const value = plain[key];
|
|
31
24
|
if (value === undefined) {
|
|
32
25
|
console.error(`key not found: ${key}`);
|
|
33
26
|
return 1;
|
|
34
27
|
}
|
|
28
|
+
if (value === '') {
|
|
29
|
+
console.error(`warning: ${key} has an empty value (treated as unset by \`athsra run\`)`);
|
|
30
|
+
}
|
|
35
31
|
console.log(value);
|
|
36
32
|
} else {
|
|
37
|
-
console.log(
|
|
33
|
+
console.log(serializeEnv(plain));
|
|
38
34
|
}
|
|
39
35
|
return 0;
|
|
40
36
|
}
|
package/src/commands/handoff.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { hostname } from 'node:os';
|
|
2
2
|
import { deriveKey, fromBase64, toBase64 } from '@athsra/crypto';
|
|
3
|
-
import { loadAuthContext } from '../lib/auth-context.ts';
|
|
3
|
+
import { loadAuthContext, type UserAuthContext } from '../lib/auth-context.ts';
|
|
4
4
|
import { AthsraClient } from '../lib/client.ts';
|
|
5
5
|
import { type Config, saveConfig } from '../lib/config.ts';
|
|
6
|
+
import { readPlain } from '../lib/envelope.ts';
|
|
6
7
|
import { probeKeyring, setMasterPw, setToken } from '../lib/keyring.ts';
|
|
7
8
|
import { promptPassword, promptText } from '../lib/prompt.ts';
|
|
8
9
|
|
|
@@ -13,8 +14,12 @@ const USAGE = [
|
|
|
13
14
|
|
|
14
15
|
async function issueToken(): Promise<number> {
|
|
15
16
|
console.log('athsra handoff (issue, 기존 머신에서)\n');
|
|
16
|
-
const ctx = loadAuthContext();
|
|
17
|
+
const ctx = await loadAuthContext();
|
|
17
18
|
if (!ctx) return 1;
|
|
19
|
+
if (ctx.kind !== 'user') {
|
|
20
|
+
console.error('athsra handoff 은 user token (master pw) 가 필요합니다.');
|
|
21
|
+
return 1;
|
|
22
|
+
}
|
|
18
23
|
const { masterPw, client } = ctx;
|
|
19
24
|
|
|
20
25
|
const newLabel = await promptText('New machine label (예: home-desktop)');
|
|
@@ -89,23 +94,22 @@ async function acceptToken(): Promise<number> {
|
|
|
89
94
|
return 1;
|
|
90
95
|
}
|
|
91
96
|
|
|
92
|
-
// master pw 검증: 첫 envelope (있으면) decrypt 시도
|
|
97
|
+
// master pw 검증: 첫 envelope (있으면) decrypt 시도 (v1/v2 dispatcher)
|
|
93
98
|
const projects = await client.listProjects();
|
|
94
99
|
const first = projects[0];
|
|
95
100
|
if (first) {
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
101
|
+
const tempCtx: UserAuthContext = {
|
|
102
|
+
kind: 'user',
|
|
103
|
+
config: { workerUrl, machineId, createdAt: me.createdAt },
|
|
104
|
+
masterPw,
|
|
105
|
+
token,
|
|
106
|
+
client,
|
|
107
|
+
};
|
|
108
|
+
try {
|
|
109
|
+
await readPlain(tempCtx, first);
|
|
110
|
+
} catch {
|
|
111
|
+
console.error('✗ master password mismatch — 옛 master pw 와 일치 X');
|
|
112
|
+
return 1;
|
|
109
113
|
}
|
|
110
114
|
}
|
|
111
115
|
|
package/src/commands/login.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
3
|
+
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
|
1
4
|
import { hostname } from 'node:os';
|
|
2
5
|
import { deriveKey, fromBase64, toBase64 } from '@athsra/crypto';
|
|
3
6
|
import { isValidPhrase, normalizePhrase, wordCount } from '../lib/bip39.ts';
|
|
@@ -7,7 +10,298 @@ import { probeKeyring, setMasterPw, setToken } from '../lib/keyring.ts';
|
|
|
7
10
|
import { consumeLegacySession } from '../lib/legacy-session.ts';
|
|
8
11
|
import { promptConfirm, promptPassword, promptText } from '../lib/prompt.ts';
|
|
9
12
|
|
|
10
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Phase 2.8 (2026-05-26) — Connect SSO 로그인 (OIDC PKCE).
|
|
15
|
+
*
|
|
16
|
+
* 흐름:
|
|
17
|
+
* 1) PKCE code_verifier + challenge 생성
|
|
18
|
+
* 2) localhost callback HTTP server start (ephemeral port)
|
|
19
|
+
* 3) browser open → Connect /authorize
|
|
20
|
+
* 4) callback ?code= → POST Connect /token (code + verifier) → access_token
|
|
21
|
+
* 5) POST worker /auth/sso (access_token + label) → athsra Bearer token
|
|
22
|
+
* 6) keyring 저장 + master pw prompt (envelope decrypt 용)
|
|
23
|
+
*
|
|
24
|
+
* E2EE 정합: SSO 는 worker 인증 layer 만. master pw 는 envelope decrypt 전용.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const SSO_DEFAULTS = {
|
|
28
|
+
/** Connect authorize endpoint */
|
|
29
|
+
authorizeUrl: 'https://login.modfolio.io/authorize',
|
|
30
|
+
/** Connect token endpoint */
|
|
31
|
+
tokenUrl: 'https://login.modfolio.io/token',
|
|
32
|
+
/** Connect 측 CLI client (PKCE public, no secret). 사용자가 별도 등록. */
|
|
33
|
+
clientId: 'athsra-cli',
|
|
34
|
+
/** OIDC scope */
|
|
35
|
+
scope: 'openid profile email',
|
|
36
|
+
/** callback timeout (ms) — 사용자 browser 작업 대기 */
|
|
37
|
+
callbackTimeoutMs: 5 * 60 * 1000,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
interface CallbackResult {
|
|
41
|
+
code: string;
|
|
42
|
+
state: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Start ephemeral loopback HTTP callback server. Closes after first valid callback or timeout. */
|
|
46
|
+
async function waitForCallback(
|
|
47
|
+
expectedState: string,
|
|
48
|
+
port: number,
|
|
49
|
+
timeoutMs: number,
|
|
50
|
+
): Promise<CallbackResult> {
|
|
51
|
+
return await new Promise<CallbackResult>((resolve, reject) => {
|
|
52
|
+
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
|
53
|
+
const url = new URL(req.url ?? '/', `http://127.0.0.1:${port}`);
|
|
54
|
+
if (url.pathname !== '/callback') {
|
|
55
|
+
res.statusCode = 404;
|
|
56
|
+
res.end('not found');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const code = url.searchParams.get('code');
|
|
60
|
+
const state = url.searchParams.get('state');
|
|
61
|
+
const error = url.searchParams.get('error');
|
|
62
|
+
if (error) {
|
|
63
|
+
res.statusCode = 400;
|
|
64
|
+
res.end(`Authorization failed: ${error}. Return to terminal.`);
|
|
65
|
+
server.close();
|
|
66
|
+
reject(new Error(`Connect authorize error: ${error}`));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (!code || !state) {
|
|
70
|
+
res.statusCode = 400;
|
|
71
|
+
res.end('Missing code or state. Return to terminal.');
|
|
72
|
+
server.close();
|
|
73
|
+
reject(new Error('Missing code or state in callback'));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (state !== expectedState) {
|
|
77
|
+
res.statusCode = 400;
|
|
78
|
+
res.end('State mismatch — possible CSRF. Return to terminal.');
|
|
79
|
+
server.close();
|
|
80
|
+
reject(new Error('State mismatch'));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
res.statusCode = 200;
|
|
84
|
+
res.setHeader('content-type', 'text/html; charset=utf-8');
|
|
85
|
+
res.end(
|
|
86
|
+
'<!doctype html><html><body style="font-family:system-ui;padding:2rem;">' +
|
|
87
|
+
'<h2>✓ athsra SSO 로그인 완료</h2>' +
|
|
88
|
+
'<p>터미널로 돌아가세요. 이 창은 닫아도 됩니다.</p>' +
|
|
89
|
+
'</body></html>',
|
|
90
|
+
);
|
|
91
|
+
server.close();
|
|
92
|
+
resolve({ code, state });
|
|
93
|
+
});
|
|
94
|
+
server.listen(port, '127.0.0.1');
|
|
95
|
+
setTimeout(() => {
|
|
96
|
+
server.close();
|
|
97
|
+
reject(new Error(`callback timeout after ${timeoutMs / 1000}s`));
|
|
98
|
+
}, timeoutMs);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function base64urlBuf(buf: Buffer): string {
|
|
103
|
+
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function openBrowser(url: string): void {
|
|
107
|
+
const platform = process.platform;
|
|
108
|
+
const cmd = platform === 'darwin' ? 'open' : platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
109
|
+
const args = platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
110
|
+
spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function findFreePort(): Promise<number> {
|
|
114
|
+
// 49152-65535 ephemeral range. createServer port=0 자동 할당.
|
|
115
|
+
return await new Promise<number>((resolve, reject) => {
|
|
116
|
+
const server = createServer();
|
|
117
|
+
server.on('error', reject);
|
|
118
|
+
server.listen(0, '127.0.0.1', () => {
|
|
119
|
+
const address = server.address();
|
|
120
|
+
if (typeof address !== 'object' || address === null) {
|
|
121
|
+
server.close();
|
|
122
|
+
reject(new Error('no port'));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const port = address.port;
|
|
126
|
+
server.close(() => resolve(port));
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function ssoLoginCmd(): Promise<number> {
|
|
132
|
+
console.log('athsra login --sso (modfolio-connect OIDC PKCE)\n');
|
|
133
|
+
|
|
134
|
+
// 1. keyring backend
|
|
135
|
+
const probe = probeKeyring();
|
|
136
|
+
if (!probe.ok) {
|
|
137
|
+
console.error(`✗ keyring backend unavailable: ${probe.error ?? 'unknown'}`);
|
|
138
|
+
return 1;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 2. config — workerUrl + machineId
|
|
142
|
+
const existing = loadConfig();
|
|
143
|
+
const envUrl = process.env.ATHSRA_WORKER_URL;
|
|
144
|
+
const workerUrl =
|
|
145
|
+
existing?.workerUrl ??
|
|
146
|
+
envUrl ??
|
|
147
|
+
(await promptText('Worker URL', 'https://athsra-worker.winterermod.workers.dev'));
|
|
148
|
+
const machineId = existing?.machineId ?? `${hostname()}-${Date.now().toString(36)}`;
|
|
149
|
+
|
|
150
|
+
// 3. worker reachability
|
|
151
|
+
const tempClient = new AthsraClient(workerUrl);
|
|
152
|
+
const reachable = await tempClient.health();
|
|
153
|
+
if (!reachable) {
|
|
154
|
+
console.error(`✗ worker unreachable: ${workerUrl}`);
|
|
155
|
+
return 1;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// 4. PKCE + state 생성
|
|
159
|
+
const codeVerifier = base64urlBuf(randomBytes(48));
|
|
160
|
+
const codeChallenge = base64urlBuf(createHash('sha256').update(codeVerifier).digest());
|
|
161
|
+
const state = base64urlBuf(randomBytes(16));
|
|
162
|
+
|
|
163
|
+
// 5. callback server start
|
|
164
|
+
let port: number;
|
|
165
|
+
try {
|
|
166
|
+
port = await findFreePort();
|
|
167
|
+
} catch (err) {
|
|
168
|
+
console.error(`✗ cannot allocate callback port: ${(err as Error).message}`);
|
|
169
|
+
return 1;
|
|
170
|
+
}
|
|
171
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
172
|
+
|
|
173
|
+
// 6. browser open
|
|
174
|
+
const authorizeUrl = process.env.ATHSRA_SSO_AUTHORIZE_URL ?? SSO_DEFAULTS.authorizeUrl;
|
|
175
|
+
const clientId = process.env.ATHSRA_SSO_CLIENT_ID ?? SSO_DEFAULTS.clientId;
|
|
176
|
+
const params = new URLSearchParams({
|
|
177
|
+
client_id: clientId,
|
|
178
|
+
response_type: 'code',
|
|
179
|
+
code_challenge: codeChallenge,
|
|
180
|
+
code_challenge_method: 'S256',
|
|
181
|
+
state,
|
|
182
|
+
redirect_uri: redirectUri,
|
|
183
|
+
scope: SSO_DEFAULTS.scope,
|
|
184
|
+
});
|
|
185
|
+
const authUrl = `${authorizeUrl}?${params.toString()}`;
|
|
186
|
+
console.log(`opening browser: ${authUrl.slice(0, 80)}...`);
|
|
187
|
+
console.log(`callback: ${redirectUri}`);
|
|
188
|
+
console.log('(complete login in browser — terminal will resume when callback received)\n');
|
|
189
|
+
openBrowser(authUrl);
|
|
190
|
+
|
|
191
|
+
// 7. wait for callback
|
|
192
|
+
let callback: CallbackResult;
|
|
193
|
+
try {
|
|
194
|
+
callback = await waitForCallback(state, port, SSO_DEFAULTS.callbackTimeoutMs);
|
|
195
|
+
} catch (err) {
|
|
196
|
+
console.error(`✗ callback failed: ${(err as Error).message}`);
|
|
197
|
+
return 1;
|
|
198
|
+
}
|
|
199
|
+
console.log('✓ callback received, exchanging code...');
|
|
200
|
+
|
|
201
|
+
// 8. token exchange (Connect /token)
|
|
202
|
+
const tokenUrl = process.env.ATHSRA_SSO_TOKEN_URL ?? SSO_DEFAULTS.tokenUrl;
|
|
203
|
+
const tokenRes = await fetch(tokenUrl, {
|
|
204
|
+
method: 'POST',
|
|
205
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
206
|
+
body: new URLSearchParams({
|
|
207
|
+
grant_type: 'authorization_code',
|
|
208
|
+
code: callback.code,
|
|
209
|
+
code_verifier: codeVerifier,
|
|
210
|
+
client_id: clientId,
|
|
211
|
+
redirect_uri: redirectUri,
|
|
212
|
+
}).toString(),
|
|
213
|
+
});
|
|
214
|
+
if (!tokenRes.ok) {
|
|
215
|
+
console.error(`✗ Connect token exchange failed: ${tokenRes.status} ${await tokenRes.text()}`);
|
|
216
|
+
return 1;
|
|
217
|
+
}
|
|
218
|
+
const tokenBody = (await tokenRes.json()) as { access_token?: string };
|
|
219
|
+
if (!tokenBody.access_token) {
|
|
220
|
+
console.error('✗ Connect token response missing access_token');
|
|
221
|
+
return 1;
|
|
222
|
+
}
|
|
223
|
+
console.log('✓ Connect access_token received');
|
|
224
|
+
|
|
225
|
+
// 9. worker /auth/sso (Connect token → athsra Bearer)
|
|
226
|
+
const ssoRes = await fetch(`${workerUrl}/auth/sso`, {
|
|
227
|
+
method: 'POST',
|
|
228
|
+
headers: { 'content-type': 'application/json' },
|
|
229
|
+
body: JSON.stringify({ access_token: tokenBody.access_token, label: machineId }),
|
|
230
|
+
});
|
|
231
|
+
if (!ssoRes.ok) {
|
|
232
|
+
console.error(`✗ athsra worker SSO failed: ${ssoRes.status} ${await ssoRes.text()}`);
|
|
233
|
+
return 1;
|
|
234
|
+
}
|
|
235
|
+
const ssoBody = (await ssoRes.json()) as {
|
|
236
|
+
token: string;
|
|
237
|
+
identifier: string;
|
|
238
|
+
expires_at?: string;
|
|
239
|
+
createdAt: string;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
// 10. master pw prompt (envelope decrypt 전용 — worker 에 송신 X)
|
|
243
|
+
console.log('\n● Master password — envelope decrypt (E2EE, worker 노출 X)');
|
|
244
|
+
const envPw = process.env.ATHSRA_MASTER_PW;
|
|
245
|
+
let masterPw: string;
|
|
246
|
+
if (envPw && envPw.length >= 8) {
|
|
247
|
+
masterPw = envPw;
|
|
248
|
+
console.log('• Master password from $ATHSRA_MASTER_PW (non-interactive).');
|
|
249
|
+
} else {
|
|
250
|
+
masterPw = await promptPassword('Master password (8+ chars)');
|
|
251
|
+
if (masterPw.length < 8) {
|
|
252
|
+
console.error('Master password must be at least 8 characters');
|
|
253
|
+
return 1;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (isValidPhrase(masterPw)) {
|
|
257
|
+
const wc = wordCount(masterPw);
|
|
258
|
+
console.log(`• Master password is a valid BIP-39 ${wc}-word phrase ✓`);
|
|
259
|
+
masterPw = normalizePhrase(masterPw);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// 11. config + keyring 저장
|
|
263
|
+
const config: Config = {
|
|
264
|
+
workerUrl,
|
|
265
|
+
machineId,
|
|
266
|
+
createdAt: existing?.createdAt ?? ssoBody.createdAt,
|
|
267
|
+
};
|
|
268
|
+
saveConfig(config);
|
|
269
|
+
setMasterPw(machineId, masterPw);
|
|
270
|
+
setToken(machineId, ssoBody.token);
|
|
271
|
+
|
|
272
|
+
console.log(`\n✓ SSO logged in (machine: ${machineId})`);
|
|
273
|
+
console.log(` identifier: ${ssoBody.identifier}`);
|
|
274
|
+
console.log(` worker: ${workerUrl}`);
|
|
275
|
+
if (ssoBody.expires_at) {
|
|
276
|
+
console.log(` expires: ${ssoBody.expires_at}`);
|
|
277
|
+
}
|
|
278
|
+
console.log(' keyring: master-pw + token saved (OS keyring)');
|
|
279
|
+
return 0;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const LOGIN_USAGE = [
|
|
283
|
+
'usage: athsra login [--sso]',
|
|
284
|
+
'',
|
|
285
|
+
'기본 (master pw + paper backup): athsra login',
|
|
286
|
+
'SSO (modfolio-connect OIDC PKCE — Phase 2.8): athsra login --sso',
|
|
287
|
+
'',
|
|
288
|
+
'환경변수:',
|
|
289
|
+
' ATHSRA_WORKER_URL worker URL (config 우선)',
|
|
290
|
+
' ATHSRA_MASTER_PW non-interactive master pw',
|
|
291
|
+
' ATHSRA_PAPER_BACKUP_CONFIRMED=1 paper-backup prompt 우회',
|
|
292
|
+
' ATHSRA_SSO_AUTHORIZE_URL Connect /authorize override',
|
|
293
|
+
' ATHSRA_SSO_TOKEN_URL Connect /token override',
|
|
294
|
+
' ATHSRA_SSO_CLIENT_ID Connect client_id (default: athsra-cli)',
|
|
295
|
+
].join('\n');
|
|
296
|
+
|
|
297
|
+
export async function loginCmd(args: string[]): Promise<number> {
|
|
298
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
299
|
+
console.log(LOGIN_USAGE);
|
|
300
|
+
return 0;
|
|
301
|
+
}
|
|
302
|
+
if (args.includes('--sso')) {
|
|
303
|
+
return ssoLoginCmd();
|
|
304
|
+
}
|
|
11
305
|
console.log('athsra login\n');
|
|
12
306
|
|
|
13
307
|
// 1. keyring backend probe (정공법: fallback 없음)
|
|
@@ -102,6 +396,17 @@ export async function loginCmd(_args: string[]): Promise<number> {
|
|
|
102
396
|
return 1;
|
|
103
397
|
}
|
|
104
398
|
|
|
399
|
+
// Phase 1.x.5: GLOBAL_SALT_VERSION change 감지 안내
|
|
400
|
+
if (info.proof_invalidated) {
|
|
401
|
+
console.log('');
|
|
402
|
+
console.log(
|
|
403
|
+
`⚠ GLOBAL_SALT_VERSION 변경 감지 (PROOF: ${info.proof_global_salt_version} → worker: ${info.global_salt_version}).`,
|
|
404
|
+
);
|
|
405
|
+
console.log(' 옛 PROOF 가 다음 register 시 자동 invalidate 됩니다.');
|
|
406
|
+
console.log(' R2 envelope 은 영향 없습니다 (per-envelope salt 사용 — secret data 보존).');
|
|
407
|
+
console.log(' 동일 master password 로 재 register 진행합니다.\n');
|
|
408
|
+
}
|
|
409
|
+
|
|
105
410
|
// 7. master_pw_proof = Argon2id(pw + GLOBAL_SALT)
|
|
106
411
|
const proofBytes = deriveKey(masterPw, fromBase64(info.global_salt));
|
|
107
412
|
const proofBase64 = toBase64(proofBytes);
|