@athsra/cli 1.1.3 → 1.1.5
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 +1 -1
- package/src/commands/doctor.ts +31 -0
- package/src/commands/login.ts +39 -12
- package/src/commands/org.ts +24 -2
- package/src/commands/recipients.ts +23 -0
- package/src/lib/oidc-flow.ts +80 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@athsra/cli",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.5",
|
|
4
4
|
"description": "athsra CLI — E2EE secret manager on Cloudflare edge. Doppler-style dev UX + zero-knowledge encryption + soft-delete + version history. MIT.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
package/src/commands/doctor.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { configFile, loadConfig, sessionFile } from '../lib/config.ts';
|
|
|
5
5
|
import { partitionEnv } from '../lib/env-format.ts';
|
|
6
6
|
import { readPlain } from '../lib/envelope.ts';
|
|
7
7
|
import { getMasterPw, getToken, probeKeyring } from '../lib/keyring.ts';
|
|
8
|
+
import { summarizeMemberCoverage } from './recipients.ts';
|
|
8
9
|
|
|
9
10
|
export async function doctorCmd(args: string[]): Promise<number> {
|
|
10
11
|
const audit = args.includes('--audit');
|
|
@@ -68,13 +69,28 @@ export async function doctorCmd(args: string[]): Promise<number> {
|
|
|
68
69
|
console.log(` info: error — ${(err as Error).message}`);
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
let selfUserId: number | undefined;
|
|
71
73
|
if (token) {
|
|
72
74
|
try {
|
|
73
75
|
const me = await client.whoami();
|
|
76
|
+
selfUserId = me.userId;
|
|
74
77
|
console.log(` whoami: machineId=${me.machineId} lastSeenAt=${me.lastSeenAt}`);
|
|
75
78
|
} catch (err) {
|
|
76
79
|
console.log(` whoami: ✗ ${(err as Error).message}`);
|
|
77
80
|
}
|
|
81
|
+
// identity key (server) — 다른 머신·위치의 브라우저 device-login 이 sealed key 를 받는 전제.
|
|
82
|
+
try {
|
|
83
|
+
const hasIdentityKey = (await client.getKeys()) !== null;
|
|
84
|
+
console.log(
|
|
85
|
+
` identity key (server): ${
|
|
86
|
+
hasIdentityKey
|
|
87
|
+
? '✓ provisioned (어디서든 device-login 가능)'
|
|
88
|
+
: '✗ missing — 다른 머신 device-login 불가. `athsra login` 재실행으로 생성.'
|
|
89
|
+
}`,
|
|
90
|
+
);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
console.log(` identity key (server): ? ${(err as Error).message}`);
|
|
93
|
+
}
|
|
78
94
|
try {
|
|
79
95
|
const projects = await client.listProjects();
|
|
80
96
|
const head = projects.slice(0, 5).join(', ');
|
|
@@ -82,6 +98,21 @@ export async function doctorCmd(args: string[]): Promise<number> {
|
|
|
82
98
|
console.log(
|
|
83
99
|
` projects: ${projects.length}${projects.length ? ` (${head}${more})` : ''}`,
|
|
84
100
|
);
|
|
101
|
+
// recipient 커버리지 — 브라우저 로그인(identity) 머신이 master pw 없이 복호 가능한 프로젝트 수.
|
|
102
|
+
if (selfUserId !== undefined && projects.length > 0) {
|
|
103
|
+
const uid = selfUserId;
|
|
104
|
+
const envs = await Promise.all(
|
|
105
|
+
projects.map((p) => client.getEnvelope(p).catch(() => null)),
|
|
106
|
+
);
|
|
107
|
+
const cov = summarizeMemberCoverage(projects, envs, uid);
|
|
108
|
+
console.log(
|
|
109
|
+
` recipient coverage: ${cov.covered}/${cov.total} member-accessible (브라우저 로그인으로 복호)`,
|
|
110
|
+
);
|
|
111
|
+
if (cov.gaps.length > 0) {
|
|
112
|
+
console.log(` ⚠ master-only (브라우저 로그인 복호 불가): ${cov.gaps.join(', ')}`);
|
|
113
|
+
console.log(' → 소유자가 `athsra migrate-envelopes --self --apply` 로 보강.');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
85
116
|
} catch (err) {
|
|
86
117
|
console.log(` projects: fetch error — ${(err as Error).message}`);
|
|
87
118
|
}
|
package/src/commands/login.ts
CHANGED
|
@@ -16,7 +16,12 @@ import { readPlain } from '../lib/envelope.ts';
|
|
|
16
16
|
import { ensureKeypair } from '../lib/identity-key.ts';
|
|
17
17
|
import { probeKeyring, setDeviceToken, setMasterPw, setToken } from '../lib/keyring.ts';
|
|
18
18
|
import { consumeLegacySession } from '../lib/legacy-session.ts';
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
openBrowser,
|
|
21
|
+
resolveOidcEndpoints,
|
|
22
|
+
runOidcPkceFlow,
|
|
23
|
+
SSO_DEFAULTS,
|
|
24
|
+
} from '../lib/oidc-flow.ts';
|
|
20
25
|
import { promptConfirm, promptPassword, promptText } from '../lib/prompt.ts';
|
|
21
26
|
|
|
22
27
|
/**
|
|
@@ -25,13 +30,25 @@ import { promptConfirm, promptPassword, promptText } from '../lib/prompt.ts';
|
|
|
25
30
|
* 실제 공유는 Slice 6. master pw 로 wrap 된 privkey 만 server 보관(평문 미노출).
|
|
26
31
|
*/
|
|
27
32
|
async function provisionIdentityKey(client: AthsraClient, masterPw: string): Promise<void> {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
// identity key = "어디서든 브라우저 device-login" 의 substrate (다른 머신이 sealed key 를 받는 전제).
|
|
34
|
+
// transient 실패에 1회 재시도, 영구 실패면 silent 가 아니라 결과(다른 머신 device-login 불가)를
|
|
35
|
+
// 명시 + 멱등 복구 경로 안내. master-pw 사용 자체는 정상이므로 login 을 hard-fail 하진 않는다.
|
|
36
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
37
|
+
try {
|
|
38
|
+
const created = await ensureKeypair(client, masterPw);
|
|
39
|
+
console.log(created ? ' identity key: provisioned ✓' : ' identity key: present ✓');
|
|
40
|
+
return;
|
|
41
|
+
} catch (err) {
|
|
42
|
+
if (attempt < 2) continue;
|
|
43
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
44
|
+
console.warn(` ⚠ identity key 미provisioning (${msg})`);
|
|
45
|
+
console.warn(
|
|
46
|
+
' → 다른 머신·위치에서 브라우저 device-login 이 불가합니다 (이 머신의 master-pw 접근은 정상).',
|
|
47
|
+
);
|
|
48
|
+
console.warn(
|
|
49
|
+
' → 복구: 네트워크 확인 후 `athsra login` 재실행(멱등). 상태는 `athsra doctor` 로 확인.',
|
|
50
|
+
);
|
|
51
|
+
}
|
|
35
52
|
}
|
|
36
53
|
}
|
|
37
54
|
|
|
@@ -81,9 +98,18 @@ async function ssoLoginCmd(): Promise<number> {
|
|
|
81
98
|
// env override 해석 후 lib/oidc-flow.ts 에 위임. 실패 시 throw → `✗ ...` + exit 1.
|
|
82
99
|
let accessToken: string;
|
|
83
100
|
try {
|
|
101
|
+
// endpoint 는 OIDC discovery 로 해석 (하드코딩 /authorize 금지 — connect 는 /sso/* 로 광고).
|
|
102
|
+
const endpoints = await resolveOidcEndpoints(
|
|
103
|
+
{ discoveryUrl: process.env.ATHSRA_SSO_DISCOVERY_URL ?? SSO_DEFAULTS.discoveryUrl },
|
|
104
|
+
{
|
|
105
|
+
authorizeUrl: process.env.ATHSRA_SSO_AUTHORIZE_URL,
|
|
106
|
+
tokenUrl: process.env.ATHSRA_SSO_TOKEN_URL,
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
console.log(` authorize: ${endpoints.authorizeUrl}`);
|
|
84
110
|
const flow = await runOidcPkceFlow({
|
|
85
|
-
authorizeUrl:
|
|
86
|
-
tokenUrl:
|
|
111
|
+
authorizeUrl: endpoints.authorizeUrl,
|
|
112
|
+
tokenUrl: endpoints.tokenUrl,
|
|
87
113
|
clientId: process.env.ATHSRA_SSO_CLIENT_ID ?? SSO_DEFAULTS.clientId,
|
|
88
114
|
scope: SSO_DEFAULTS.scope,
|
|
89
115
|
callbackTimeoutMs: SSO_DEFAULTS.callbackTimeoutMs,
|
|
@@ -424,8 +450,9 @@ const LOGIN_USAGE = [
|
|
|
424
450
|
' ATHSRA_WORKER_URL worker URL (config 우선)',
|
|
425
451
|
' ATHSRA_MASTER_PW non-interactive master pw',
|
|
426
452
|
' ATHSRA_PAPER_BACKUP_CONFIRMED=1 paper-backup prompt 우회',
|
|
427
|
-
'
|
|
428
|
-
'
|
|
453
|
+
' ATHSRA_SSO_DISCOVERY_URL OIDC discovery URL (default: login.modfolio.io/.well-known/openid-configuration)',
|
|
454
|
+
' ATHSRA_SSO_AUTHORIZE_URL authorize endpoint override (둘 다 설정 시 discovery 생략)',
|
|
455
|
+
' ATHSRA_SSO_TOKEN_URL token endpoint override (둘 다 설정 시 discovery 생략)',
|
|
429
456
|
' ATHSRA_SSO_CLIENT_ID Connect client_id (default: athsra-cli)',
|
|
430
457
|
].join('\n');
|
|
431
458
|
|
package/src/commands/org.ts
CHANGED
|
@@ -22,7 +22,7 @@ const USAGE = [
|
|
|
22
22
|
' athsra org create <name> — company org 생성 (owner)',
|
|
23
23
|
' athsra org ls — 내가 멤버인 org 목록 (* = 현재)',
|
|
24
24
|
' athsra org members — 현재 org 멤버',
|
|
25
|
-
' athsra org invite <identifier> [--role=member|admin] — 멤버 초대 (
|
|
25
|
+
' athsra org invite <identifier> [--role=member|admin] [--grant] — 멤버 초대 (--grant: 공유 즉시 re-wrap)',
|
|
26
26
|
' athsra org remove <user_id> — 멤버 제거 (owner 면 DEK 자동 회전)',
|
|
27
27
|
' athsra org rotate-after-removal <user_id> — 제거 후 DEK 회전 재개 (부분 실패 복구, owner)',
|
|
28
28
|
' athsra org grant-access <identifier|id> [--replace] — 멤버에게 공유 시크릿 re-wrap (--replace=key reset 복구)',
|
|
@@ -130,7 +130,7 @@ async function membersCmd(): Promise<number> {
|
|
|
130
130
|
async function inviteCmd(args: string[]): Promise<number> {
|
|
131
131
|
const identifier = args.filter((a) => !a.startsWith('-'))[0];
|
|
132
132
|
if (!identifier) {
|
|
133
|
-
console.error('usage: athsra org invite <identifier> [--role=member|admin]');
|
|
133
|
+
console.error('usage: athsra org invite <identifier> [--role=member|admin] [--grant]');
|
|
134
134
|
return 2;
|
|
135
135
|
}
|
|
136
136
|
let role: 'member' | 'admin' = 'member';
|
|
@@ -153,6 +153,28 @@ async function inviteCmd(args: string[]): Promise<number> {
|
|
|
153
153
|
console.log(
|
|
154
154
|
' 초대받은 사람: athsra login → `athsra org ls` 에 pending 표시 → `athsra org use` 로 수락',
|
|
155
155
|
);
|
|
156
|
+
// --grant: 초대와 동시에 공유 시크릿 re-wrap(member recipient 추가) — 멤버가 이미 identity 키를
|
|
157
|
+
// provisioning(로그인)했다면 1-command 으로 접근 부여. 키 부재면 명시 안내(로그인 후 grant-access).
|
|
158
|
+
if (args.includes('--grant')) {
|
|
159
|
+
try {
|
|
160
|
+
const g = await grantOrgAccess(ctx.client, ctx.masterPw, res.user_id, {});
|
|
161
|
+
if (g.granted.length > 0) {
|
|
162
|
+
console.log(`✓ 공유 re-wrap — granted ${g.granted.length}: ${g.granted.join(', ')}`);
|
|
163
|
+
} else {
|
|
164
|
+
console.log(
|
|
165
|
+
' (re-wrap 대상 없음 — 멤버 identity 키 미provisioning 또는 v2 envelope 없음. 멤버 `athsra login` 후 `athsra org grant-access` 재시도)',
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
if (g.legacyV1.length > 0) {
|
|
169
|
+
console.log(` v1 envelope(먼저 \`athsra migrate-envelopes\`): ${g.legacyV1.join(', ')}`);
|
|
170
|
+
}
|
|
171
|
+
for (const f of g.failed) console.error(` ✗ ${f.project}: ${f.error}`);
|
|
172
|
+
} catch (err) {
|
|
173
|
+
console.warn(
|
|
174
|
+
` ⚠ 자동 공유 실패 (${(err as Error).message}) — 멤버 \`athsra login\` 후 \`athsra org grant-access ${identifier}\`.`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
156
178
|
return 0;
|
|
157
179
|
} catch (err) {
|
|
158
180
|
const msg = (err as Error).message;
|
|
@@ -38,6 +38,29 @@ export async function recipientsCmd(args: string[]): Promise<number> {
|
|
|
38
38
|
return listRecipientsCmd(args);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* member-recipient 커버리지 요약 — browser-login(identity) 머신이 복호 가능한 프로젝트 수.
|
|
43
|
+
* envelope 에 `member:<userId>` 가 있어야 master pw 없이 복호된다 (`athsra doctor` 가 사용).
|
|
44
|
+
* 순수 함수 (네트워크 X) — fetch 한 envelope 배열을 받아 집계만 한다.
|
|
45
|
+
*/
|
|
46
|
+
export function summarizeMemberCoverage(
|
|
47
|
+
projects: string[],
|
|
48
|
+
envelopes: ({ version: number; recipients?: { id: string }[] } | null)[],
|
|
49
|
+
userId: number,
|
|
50
|
+
): { covered: number; total: number; gaps: string[] } {
|
|
51
|
+
const gaps: string[] = [];
|
|
52
|
+
let covered = 0;
|
|
53
|
+
projects.forEach((project, i) => {
|
|
54
|
+
const env = envelopes[i];
|
|
55
|
+
if (env && env.version === 2 && env.recipients?.some((r) => r.id === `member:${userId}`)) {
|
|
56
|
+
covered++;
|
|
57
|
+
} else {
|
|
58
|
+
gaps.push(project);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
return { covered, total: projects.length, gaps };
|
|
62
|
+
}
|
|
63
|
+
|
|
41
64
|
async function listRecipientsCmd(args: string[]): Promise<number> {
|
|
42
65
|
if (args.includes('--help') || args.includes('-h')) {
|
|
43
66
|
console.log(USAGE);
|
package/src/lib/oidc-flow.ts
CHANGED
|
@@ -13,11 +13,11 @@ import { createHash, randomBytes } from 'node:crypto';
|
|
|
13
13
|
import { readFileSync } from 'node:fs';
|
|
14
14
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
|
15
15
|
|
|
16
|
-
/** OIDC PKCE 플로우 엔드포인트 + 파라미터 (
|
|
16
|
+
/** OIDC PKCE 플로우 엔드포인트 + 파라미터 (endpoint 는 discovery 로 해석 후 주입). */
|
|
17
17
|
export interface OidcFlowOptions {
|
|
18
|
-
/** Connect authorize endpoint */
|
|
18
|
+
/** Connect authorize endpoint (discovery 로 해석된 값) */
|
|
19
19
|
authorizeUrl: string;
|
|
20
|
-
/** Connect token endpoint */
|
|
20
|
+
/** Connect token endpoint (discovery 로 해석된 값) */
|
|
21
21
|
tokenUrl: string;
|
|
22
22
|
/** Connect 측 CLI client (PKCE public, no secret) */
|
|
23
23
|
clientId: string;
|
|
@@ -27,15 +27,88 @@ export interface OidcFlowOptions {
|
|
|
27
27
|
callbackTimeoutMs: number;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
/**
|
|
31
|
+
* SSO 기본 설정 — endpoint 는 **discovery 로 해석**하므로 하드코딩하지 않는다.
|
|
32
|
+
*
|
|
33
|
+
* connect 는 OIDC 를 `/sso/*` 아래에서만 서빙하고 discovery 문서로 정확히 광고한다.
|
|
34
|
+
* 과거 bare `/authorize` 하드코딩이 404 의 근본 원인이었다 (connect → athsra 메시지 2026-06-15).
|
|
35
|
+
*/
|
|
36
|
+
export interface SsoConfig {
|
|
37
|
+
/** OIDC/OAuth discovery 문서 URL (OIDC Discovery / RFC 8414). connect 가 `/sso/*` 를 광고. */
|
|
38
|
+
discoveryUrl: string;
|
|
39
|
+
/** 기대 issuer (discovery host 와 분리됨 — RFC 8414). connect 의 issuer 식별자. */
|
|
40
|
+
issuer: string;
|
|
41
|
+
/** Connect 측 CLI client (PKCE public, no secret). connect 에 `athsra-cli` 로 등록. */
|
|
42
|
+
clientId: string;
|
|
43
|
+
scope: string;
|
|
44
|
+
callbackTimeoutMs: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const SSO_DEFAULTS: SsoConfig = {
|
|
48
|
+
discoveryUrl: 'https://login.modfolio.io/.well-known/openid-configuration',
|
|
49
|
+
issuer: 'https://connect.modfolio.io',
|
|
34
50
|
clientId: 'athsra-cli',
|
|
35
51
|
scope: 'openid profile email',
|
|
36
52
|
callbackTimeoutMs: 5 * 60 * 1000,
|
|
37
53
|
};
|
|
38
54
|
|
|
55
|
+
/** discovery 로 해석된 endpoint 쌍. */
|
|
56
|
+
export interface ResolvedEndpoints {
|
|
57
|
+
authorizeUrl: string;
|
|
58
|
+
tokenUrl: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** OIDC discovery 문서 중 우리가 쓰는 필드만의 타입 가드 (외부 입력 → unknown 검증). */
|
|
62
|
+
function hasOidcEndpoints(
|
|
63
|
+
v: unknown,
|
|
64
|
+
): v is { authorization_endpoint: string; token_endpoint: string } {
|
|
65
|
+
if (typeof v !== 'object' || v === null) return false;
|
|
66
|
+
const o = v as Record<string, unknown>;
|
|
67
|
+
return typeof o.authorization_endpoint === 'string' && typeof o.token_endpoint === 'string';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** discovery 실패 시 폴백 — discovery URL 의 origin 에 connect 의 안정 계약(`/sso/*`) 을 붙인다. */
|
|
71
|
+
function fallbackEndpoints(discoveryUrl: string): ResolvedEndpoints {
|
|
72
|
+
const { origin } = new URL(discoveryUrl);
|
|
73
|
+
return { authorizeUrl: `${origin}/sso/authorize`, tokenUrl: `${origin}/sso/token` };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* authorize/token endpoint 를 OIDC **discovery 로 해석**한다 (정공법 — 하드코딩 경로 금지).
|
|
78
|
+
*
|
|
79
|
+
* 우선순위: 명시 override(둘 다 설정 시) > discovery 문서 > 폴백(`/sso/*`).
|
|
80
|
+
* 어떤 환경(dev/staging/prod)·향후 경로 변경에도 discovery 가 정답을 광고하므로 자동 적응한다.
|
|
81
|
+
* 부분 override(authorize 또는 token 한쪽)는 그 쪽만 고정하고 나머지는 discovery/폴백에서 채운다.
|
|
82
|
+
*/
|
|
83
|
+
export async function resolveOidcEndpoints(
|
|
84
|
+
cfg: Pick<SsoConfig, 'discoveryUrl'>,
|
|
85
|
+
overrides: { authorizeUrl?: string; tokenUrl?: string } = {},
|
|
86
|
+
): Promise<ResolvedEndpoints> {
|
|
87
|
+
// 명시 override 가 둘 다 있으면 discovery 생략 (오프라인/엣지 E2E).
|
|
88
|
+
if (overrides.authorizeUrl && overrides.tokenUrl) {
|
|
89
|
+
return { authorizeUrl: overrides.authorizeUrl, tokenUrl: overrides.tokenUrl };
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const res = await fetch(cfg.discoveryUrl, { headers: { accept: 'application/json' } });
|
|
93
|
+
if (res.ok) {
|
|
94
|
+
const doc: unknown = await res.json();
|
|
95
|
+
if (hasOidcEndpoints(doc)) {
|
|
96
|
+
return {
|
|
97
|
+
authorizeUrl: overrides.authorizeUrl ?? doc.authorization_endpoint,
|
|
98
|
+
tokenUrl: overrides.tokenUrl ?? doc.token_endpoint,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
// 네트워크/파싱 실패 → 폴백 (connect 의 `/sso/*` 는 안정 계약).
|
|
104
|
+
}
|
|
105
|
+
const fb = fallbackEndpoints(cfg.discoveryUrl);
|
|
106
|
+
return {
|
|
107
|
+
authorizeUrl: overrides.authorizeUrl ?? fb.authorizeUrl,
|
|
108
|
+
tokenUrl: overrides.tokenUrl ?? fb.tokenUrl,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
39
112
|
interface CallbackResult {
|
|
40
113
|
code: string;
|
|
41
114
|
state: string;
|