@plime-inc/cli 0.1.0 → 0.1.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 +6 -4
- package/package.json +4 -1
- package/src/ai.js +157 -0
- package/src/cli.js +56 -12
- package/src/commands.js +95 -48
- package/src/oauth.js +44 -8
package/README.md
CHANGED
|
@@ -15,13 +15,13 @@ plime logout
|
|
|
15
15
|
| --- | --- |
|
|
16
16
|
| `plime login` | ブラウザでログインし、CLIへのアクセスを許可 |
|
|
17
17
|
| `plime login --no-browser` | URLだけ表示。CLIと同じ端末のブラウザで開く |
|
|
18
|
-
| `plime whoami` | JWT署名・issuer・audience・scope
|
|
19
|
-
| `plime whoami --json` | ユーザーID
|
|
18
|
+
| `plime whoami` | JWT署名・issuer・audience・scopeを検証し、ユーザー名 → メールアドレス → IDの順で値だけを表示。期限が近いtokenは自動更新 |
|
|
19
|
+
| `plime whoami --json` | ユーザーID、取得できたユーザー名・メールアドレス、issuer、resource、scopes、有効期限をJSON出力 |
|
|
20
20
|
| `plime logout` | refresh tokenをIDPで失効させ、端末の認証情報を削除 |
|
|
21
21
|
| `plime logout --local` | オフライン時などに端末の認証情報だけを削除。IDPでは失効しない |
|
|
22
22
|
| `--dev` | ローカルIDPを使用。本番とは別に保存 |
|
|
23
23
|
|
|
24
|
-
標準出力は結果、URLやエラーは標準エラーへ出します。token
|
|
24
|
+
標準出力は結果、URLやエラーは標準エラーへ出します。token自体を表示するコマンドはありません。表示用のユーザー名・メールアドレスはIDPが署名したtokenから取得します。氏名(`name`)をユーザー名として扱いません。`login`・`whoami`の通常出力に環境名は表示しません。未ログイン・拒否・通信失敗は終了コード1、中断は130です。
|
|
25
25
|
|
|
26
26
|
認証情報は `$XDG_CONFIG_HOME/plime/<environment>/session.json`(未指定時 `~/.config/plime/<environment>/session.json`)へ保存します。POSIXではディレクトリ700、ファイル600です。暗号化保管やOS Keychainへの保存ではありません。Windowsでは保存先のユーザーACLに依存します。
|
|
27
27
|
|
|
@@ -31,6 +31,8 @@ plime logout
|
|
|
31
31
|
|
|
32
32
|
利用開始にはIDPのmigration・公式CLIのprovisionと、accountsのログイン・同意画面のデプロイが必要です。`--dev`ではIDP localhost:8787、accounts localhost:3003を起動し、開発DBでもprovisionしてください。
|
|
33
33
|
|
|
34
|
-
AI
|
|
34
|
+
AI接続は開発中です。`plime ai jev --json` は標準入力の `{ "state": ..., "questions": ... }` をdecision APIへ送り、結果をJSONで返します。`plime ai text "質問" --model provider/model` と `plime ai models --type decision` も追加しています。個人組織はIDPが署名したtokenから取得するため、以前のログインでは再ログインが必要な場合があります。
|
|
35
|
+
|
|
36
|
+
課金の受付・消費記録との接続、画像とファイル入力、job操作は未完了です。現時点のAIコマンドを完成版として公開しないでください。
|
|
35
37
|
|
|
36
38
|
検証: package rootで `vp test run` と `vp lint`。実Better AuthとのOAuth統合テストはmonorepo内のIDPテストfixtureを使います。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plime-inc/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "PLIME command-line client",
|
|
5
5
|
"author": "@plime-kitajima",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
"bin": {
|
|
13
13
|
"plime": "bin/plime.js"
|
|
14
14
|
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"publish": "npm publish --ignore-scripts --access public --registry=https://registry.npmjs.org/ --@plime-inc:registry=https://registry.npmjs.org/"
|
|
17
|
+
},
|
|
15
18
|
"files": [
|
|
16
19
|
"bin/",
|
|
17
20
|
"src/",
|
package/src/ai.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
const capabilities = {
|
|
2
|
+
text: 'text.generate',
|
|
3
|
+
image: 'image.generate',
|
|
4
|
+
decision: 'decision.evaluate',
|
|
5
|
+
embed: 'embed.generate',
|
|
6
|
+
video: 'video.generate',
|
|
7
|
+
audio: 'audio.transcribe',
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const readInput = async (stdin) => {
|
|
11
|
+
if (stdin.isTTY) return ''
|
|
12
|
+
|
|
13
|
+
const chunks = []
|
|
14
|
+
let length = 0
|
|
15
|
+
|
|
16
|
+
for await (const chunk of stdin) {
|
|
17
|
+
const bytes = Buffer.from(chunk)
|
|
18
|
+
length += bytes.length
|
|
19
|
+
if (length > 1024 * 1024) throw new Error('Input exceeds 1 MiB.')
|
|
20
|
+
|
|
21
|
+
chunks.push(bytes)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return Buffer.concat(chunks).toString('utf8')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const parseTarget = (model) => {
|
|
28
|
+
const separator = model?.indexOf('/') ?? -1
|
|
29
|
+
if (separator < 1 || separator === model.length - 1) throw new Error('Use --model <provider/model>.')
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
provider: model.slice(0, separator),
|
|
33
|
+
model: model.slice(separator + 1),
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const createAiClient = ({ config, commands, signal, fetch: fetcher = globalThis.fetch }) => {
|
|
38
|
+
const request = (path, { body, query } = {}) => {
|
|
39
|
+
const operation = async ({ accessToken, identity }) => {
|
|
40
|
+
const url = new URL(`/api/${path}`, config.resource)
|
|
41
|
+
if (url.origin !== new URL(config.resource).origin) throw new Error('Unexpected AI endpoint.')
|
|
42
|
+
|
|
43
|
+
if (query) url.search = new URLSearchParams(query).toString()
|
|
44
|
+
|
|
45
|
+
const headers = {
|
|
46
|
+
authorization: `Bearer ${accessToken}`,
|
|
47
|
+
accept: 'application/json',
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let payload
|
|
51
|
+
|
|
52
|
+
if (body) {
|
|
53
|
+
const organizationId = identity.personalOrganizationId
|
|
54
|
+
if (!organizationId) {
|
|
55
|
+
throw new Error('Login does not include a personal organization. Run plime logout, then plime login.')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
payload = { ...body, metadata: { organizationId } }
|
|
59
|
+
headers['content-type'] = 'application/json'
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const timeout = AbortSignal.timeout(120_000)
|
|
63
|
+
|
|
64
|
+
const response = await fetcher(url, {
|
|
65
|
+
method: body ? 'POST' : 'GET',
|
|
66
|
+
headers,
|
|
67
|
+
body: payload ? JSON.stringify(payload) : undefined,
|
|
68
|
+
signal: signal ? AbortSignal.any([ signal, timeout ]) : timeout,
|
|
69
|
+
redirect: 'error',
|
|
70
|
+
})
|
|
71
|
+
// APIが返す本文やHTMLには認証情報が混じり得るため、失敗時にそのまま表示しない。
|
|
72
|
+
if (!response.ok) throw new Error(`AI request failed (HTTP ${response.status}).`)
|
|
73
|
+
if (!response.headers.get('content-type')?.includes('application/json')) {
|
|
74
|
+
throw new Error('AI returned an unexpected response type.')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const result = await response.json()
|
|
78
|
+
if (result.error) throw new Error('AI request failed.')
|
|
79
|
+
|
|
80
|
+
return result.data ?? result
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return commands.authenticated(operation)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const models = async (type = 'text') => {
|
|
87
|
+
const capability = capabilities[type]
|
|
88
|
+
if (!capability) throw new Error('Unknown model type.')
|
|
89
|
+
|
|
90
|
+
const { targets } = await request('catalog/targets', { query: { capability } })
|
|
91
|
+
|
|
92
|
+
const listProviderModels = async ({ provider }) => {
|
|
93
|
+
const catalog = await request(`catalog/targets/${encodeURIComponent(provider)}/models`, { query: { capability } })
|
|
94
|
+
return catalog.models.map(model => ({ ...model, id: `${provider}/${model.id}` }))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const results = await Promise.all(targets.map(listProviderModels))
|
|
98
|
+
|
|
99
|
+
return results.flat()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const generate = (kind, input, model) => {
|
|
103
|
+
return request(kind, {
|
|
104
|
+
body: { ...parseTarget(model), input, response: { mode: 'wait' } },
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { models, generate }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const runAi = async (args, values, { client, stdin, stdout }) => {
|
|
112
|
+
const [ command, ...rest ] = args
|
|
113
|
+
let result
|
|
114
|
+
|
|
115
|
+
if (command === 'models') {
|
|
116
|
+
if (rest.length) throw new Error('Use plime ai models --type <type>.')
|
|
117
|
+
|
|
118
|
+
result = await client.models(values.type)
|
|
119
|
+
const output = values.json ? JSON.stringify(result) : result.map(model => model.id).join('\n')
|
|
120
|
+
stdout.write(`${output}\n`)
|
|
121
|
+
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (command === 'jev') {
|
|
126
|
+
if (rest.length) throw new Error('Pass decision input as JSON on stdin.')
|
|
127
|
+
|
|
128
|
+
let input
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
input = JSON.parse(await readInput(stdin))
|
|
132
|
+
} catch {
|
|
133
|
+
throw new Error('Decision input must be JSON containing state and questions.')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// npm配布CLIにはprivateな@plime-inc/utilsを持ち込まない。
|
|
137
|
+
// eslint-disable-next-line plime-utils/prefer-shared-predicate
|
|
138
|
+
if (!input || Array.isArray(input) || typeof input !== 'object') throw new Error('Decision input must be a JSON object.')
|
|
139
|
+
|
|
140
|
+
result = await client.generate('decision', input, values.model || 'typesafe/jev-latest')
|
|
141
|
+
} else {
|
|
142
|
+
const prompt = command === 'text' ? rest.join(' ') : args.join(' ')
|
|
143
|
+
if (!prompt) throw new Error('Provide a prompt for plime ai text.')
|
|
144
|
+
|
|
145
|
+
const content = [ prompt, await readInput(stdin) ].filter(Boolean).join('\n\n')
|
|
146
|
+
|
|
147
|
+
result = await client.generate('text', {
|
|
148
|
+
messages: [ { role: 'user', content } ],
|
|
149
|
+
options: { stream: false },
|
|
150
|
+
}, values.model)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const output = values.json || command === 'jev' ? JSON.stringify(result) : result.result?.text
|
|
154
|
+
if (typeof output !== 'string') throw new Error('AI returned an unexpected result.')
|
|
155
|
+
|
|
156
|
+
stdout.write(`${output}\n`)
|
|
157
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -2,13 +2,17 @@ import { parseArgs } from 'node:util'
|
|
|
2
2
|
import { getConfig } from './config.js'
|
|
3
3
|
import { createStore, getStoreDirectory } from './store.js'
|
|
4
4
|
import { createCommands } from './commands.js'
|
|
5
|
+
import { createAiClient, runAi } from './ai.js'
|
|
5
6
|
|
|
6
7
|
const help = `Usage: plime <command> [options]
|
|
7
8
|
|
|
8
9
|
Commands:
|
|
9
10
|
login Sign in using your browser
|
|
10
11
|
logout Revoke the CLI login and remove local tokens
|
|
11
|
-
whoami Show the authenticated
|
|
12
|
+
whoami Show the authenticated account
|
|
13
|
+
ai text <prompt> Generate text (--model <provider/model>)
|
|
14
|
+
ai jev Evaluate JSON {state, questions} from stdin
|
|
15
|
+
ai models List models (--type text|image|decision|embed|video|audio)
|
|
12
16
|
|
|
13
17
|
Options:
|
|
14
18
|
--dev Use local IDP with separate login storage
|
|
@@ -19,34 +23,69 @@ Options:
|
|
|
19
23
|
--version, -v Show version
|
|
20
24
|
`
|
|
21
25
|
|
|
22
|
-
export const run = async (args, { stdout = process.stdout, stderr = process.stderr, signal, env = process.env } = {}) => {
|
|
26
|
+
export const run = async (args, { stdout = process.stdout, stderr = process.stderr, stdin = process.stdin, signal, env = process.env } = {}) => {
|
|
23
27
|
const { values, positionals } = parseArgs({
|
|
24
|
-
args,
|
|
28
|
+
args,
|
|
29
|
+
allowPositionals: true,
|
|
30
|
+
strict: true,
|
|
25
31
|
options: {
|
|
26
|
-
dev: { type: 'boolean' },
|
|
27
|
-
|
|
28
|
-
|
|
32
|
+
dev: { type: 'boolean' },
|
|
33
|
+
json: { type: 'boolean' },
|
|
34
|
+
'no-browser': { type: 'boolean' },
|
|
35
|
+
local: { type: 'boolean' },
|
|
36
|
+
model: { type: 'string' },
|
|
37
|
+
type: { type: 'string' },
|
|
38
|
+
help: { type: 'boolean', short: 'h' },
|
|
39
|
+
version: { type: 'boolean', short: 'v' },
|
|
29
40
|
},
|
|
30
41
|
})
|
|
42
|
+
|
|
31
43
|
if (values.version) {
|
|
32
44
|
const { default: manifest } = await import('../package.json', { with: { type: 'json' } })
|
|
33
45
|
stdout.write(`${manifest.version}\n`)
|
|
34
46
|
return
|
|
35
47
|
}
|
|
36
|
-
|
|
48
|
+
|
|
49
|
+
if (values.help || !positionals.length) {
|
|
50
|
+
stdout.write(help)
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
|
|
37
54
|
const [ command ] = positionals
|
|
38
|
-
if (
|
|
55
|
+
if (![ 'login', 'logout', 'whoami', 'ai' ].includes(command)) throw new Error('Unknown command. Run plime --help.')
|
|
56
|
+
if (command !== 'ai' && positionals.length !== 1) throw new Error('Unknown command. Run plime --help.')
|
|
39
57
|
if (values['no-browser'] && command !== 'login') throw new Error('--no-browser is only available for login.')
|
|
40
58
|
if (values.local && command !== 'logout') throw new Error('--local is only available for logout.')
|
|
59
|
+
if (command !== 'ai' && (values.model || values.type)) throw new Error('--model and --type are only available for ai.')
|
|
60
|
+
if (command === 'ai' && positionals[1] === 'request') throw new Error('Unknown command. Run plime --help.')
|
|
61
|
+
|
|
41
62
|
const config = getConfig(values.dev)
|
|
42
63
|
const store = createStore(getStoreDirectory(config.environment, env))
|
|
43
|
-
|
|
64
|
+
|
|
65
|
+
const commands = createCommands({
|
|
66
|
+
config,
|
|
67
|
+
store,
|
|
68
|
+
signal,
|
|
69
|
+
report: (message) => stderr.write(`${message}\n`),
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
if (command === 'ai') {
|
|
73
|
+
const client = createAiClient({ config, commands, signal })
|
|
74
|
+
return runAi(positionals.slice(1), values, { client, stdin, stdout })
|
|
75
|
+
}
|
|
76
|
+
|
|
44
77
|
const result = await commands[command]({ noBrowser: values['no-browser'], local: values.local })
|
|
45
|
-
|
|
78
|
+
|
|
79
|
+
if (values.json) {
|
|
80
|
+
stdout.write(`${JSON.stringify(result)}\n`)
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
46
84
|
if (command === 'logout') {
|
|
47
85
|
stdout.write(values.local ? 'Local login removed. Server tokens were not revoked.\n' : 'Logged out.\n')
|
|
48
86
|
} else {
|
|
49
|
-
|
|
87
|
+
const account = result.username || result.email || result.userId
|
|
88
|
+
stdout.write(`${command === 'login' ? 'Logged in as: ' : ''}${account}\n`)
|
|
50
89
|
}
|
|
51
90
|
}
|
|
52
91
|
|
|
@@ -55,6 +94,11 @@ export const errorMessage = (error) => {
|
|
|
55
94
|
if (error.error === 'access_denied') return 'Login was denied.'
|
|
56
95
|
if (error.error) return 'OAuth request failed. Check the IDP configuration and try logging in again.'
|
|
57
96
|
if ([ 'AbortError', 'TimeoutError' ].includes(error.name)) return 'Request cancelled or timed out.'
|
|
58
|
-
|
|
97
|
+
|
|
98
|
+
if (error.code?.startsWith('OAUTH_')) {
|
|
99
|
+
const status = error.cause instanceof Response ? ` (HTTP ${error.cause.status})` : ''
|
|
100
|
+
return `OAuth response validation failed${status}. Check the IDP and try logging in again.`
|
|
101
|
+
}
|
|
102
|
+
|
|
59
103
|
return error.message || 'Command failed.'
|
|
60
104
|
}
|
package/src/commands.js
CHANGED
|
@@ -1,60 +1,107 @@
|
|
|
1
1
|
import { createOAuth, createLoginProof } from './oauth.js'
|
|
2
2
|
import { createLoopback, openBrowser } from './loopback.js'
|
|
3
3
|
|
|
4
|
-
export const createCommands = ({
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
return oauth.identity(session.accessToken)
|
|
21
|
-
} finally {
|
|
22
|
-
await loopback.close()
|
|
23
|
-
}
|
|
24
|
-
}, signal)
|
|
25
|
-
|
|
26
|
-
const whoami = () => store.withLock(async () => {
|
|
27
|
-
let session = await store.read()
|
|
28
|
-
if (!session) throw new Error('Not logged in. Run plime login.')
|
|
29
|
-
if (session.issuer !== config.issuer || session.resource !== config.resource) throw new Error('Stored login does not match this environment.')
|
|
30
|
-
const oauth = await oauthFactory(config, { signal })
|
|
31
|
-
if (session.expiresAt <= Date.now() + 60_000) {
|
|
4
|
+
export const createCommands = ({
|
|
5
|
+
config,
|
|
6
|
+
store,
|
|
7
|
+
signal,
|
|
8
|
+
report,
|
|
9
|
+
oauthFactory = createOAuth,
|
|
10
|
+
browser = openBrowser,
|
|
11
|
+
}) => {
|
|
12
|
+
const login = ({ noBrowser = false } = {}) => {
|
|
13
|
+
const operation = async () => {
|
|
14
|
+
if (await store.read()) throw new Error('Already logged in. Run plime logout before switching accounts.')
|
|
15
|
+
|
|
16
|
+
const oauth = await oauthFactory(config, { signal })
|
|
17
|
+
const { state, verifier } = createLoginProof()
|
|
18
|
+
const loopback = await createLoopback({ path: config.callbackPath, state, signal })
|
|
19
|
+
|
|
32
20
|
try {
|
|
33
|
-
|
|
21
|
+
const url = await oauth.authorize(loopback.redirectUri, state, verifier)
|
|
22
|
+
report(`Open this URL to sign in:\n${url}`)
|
|
23
|
+
|
|
24
|
+
if (!noBrowser) {
|
|
25
|
+
// ブラウザ起動コマンドの終了待ちでcallback受信を止めない。
|
|
26
|
+
const reportBrowser = (opened) => {
|
|
27
|
+
if (!opened) report('Open the URL above in your browser.')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
void browser(url).then(reportBrowser)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const callback = await loopback.callback
|
|
34
|
+
const session = await oauth.exchange(callback, loopback.redirectUri, state, verifier)
|
|
34
35
|
await store.write(session)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
|
|
37
|
+
return oauth.identity(session.accessToken)
|
|
38
|
+
} finally {
|
|
39
|
+
await loopback.close()
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return store.withLock(operation, signal)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const readAuthorization = () => {
|
|
47
|
+
const operation = async () => {
|
|
48
|
+
let session = await store.read()
|
|
49
|
+
if (!session) throw new Error('Not logged in. Run plime login.')
|
|
50
|
+
if (session.issuer !== config.issuer || session.resource !== config.resource) {
|
|
51
|
+
throw new Error('Stored login does not match this environment.')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const oauth = await oauthFactory(config, { signal })
|
|
55
|
+
|
|
56
|
+
if (session.expiresAt <= Date.now() + 60_000) {
|
|
57
|
+
try {
|
|
58
|
+
session = await oauth.refresh(session.refreshToken)
|
|
59
|
+
await store.write(session)
|
|
60
|
+
} catch (err) {
|
|
61
|
+
if (err.error === 'invalid_grant') {
|
|
62
|
+
await store.remove()
|
|
63
|
+
throw new Error('Login expired or was revoked. Run plime login again.')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
throw err
|
|
39
67
|
}
|
|
40
|
-
throw err
|
|
41
68
|
}
|
|
69
|
+
|
|
70
|
+
const identity = await oauth.identity(session.accessToken)
|
|
71
|
+
|
|
72
|
+
return { accessToken: session.accessToken, identity }
|
|
42
73
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
74
|
+
|
|
75
|
+
return store.withLock(operation, signal)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const whoami = async () => (await readAuthorization()).identity
|
|
79
|
+
|
|
80
|
+
// token更新だけをlock内に置き、AIの実行待ちで他プロセスの認証を止めない。
|
|
81
|
+
const authenticated = async (operation) => operation(await readAuthorization())
|
|
82
|
+
|
|
83
|
+
const logout = ({ local = false } = {}) => {
|
|
84
|
+
const operation = async () => {
|
|
85
|
+
if (!local) {
|
|
86
|
+
const session = await store.read()
|
|
87
|
+
|
|
88
|
+
if (session) {
|
|
89
|
+
if (session.issuer !== config.issuer || session.resource !== config.resource) {
|
|
90
|
+
throw new Error('Stored login does not match this environment.')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const oauth = await oauthFactory(config, { signal })
|
|
94
|
+
await oauth.revoke(session.refreshToken)
|
|
95
|
+
}
|
|
53
96
|
}
|
|
97
|
+
|
|
98
|
+
await store.remove()
|
|
99
|
+
|
|
100
|
+
return { loggedOut: true, localOnly: local }
|
|
54
101
|
}
|
|
55
|
-
await store.remove()
|
|
56
|
-
return { loggedOut: true, localOnly: local }
|
|
57
|
-
}, signal)
|
|
58
102
|
|
|
59
|
-
|
|
103
|
+
return store.withLock(operation, signal)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { login, whoami, logout, authenticated }
|
|
60
107
|
}
|
package/src/oauth.js
CHANGED
|
@@ -2,26 +2,32 @@ import * as oauth from 'oauth4webapi'
|
|
|
2
2
|
|
|
3
3
|
export const createOAuth = async (config, { signal, fetch: fetcher = globalThis.fetch } = {}) => {
|
|
4
4
|
const issuer = new URL(config.issuer)
|
|
5
|
+
|
|
5
6
|
const options = () => ({
|
|
6
7
|
signal: signal ? AbortSignal.any([ signal, AbortSignal.timeout(30_000) ]) : AbortSignal.timeout(30_000),
|
|
7
8
|
[oauth.allowInsecureRequests]: config.development,
|
|
8
9
|
[oauth.customFetch]: fetcher,
|
|
9
10
|
})
|
|
11
|
+
|
|
10
12
|
// discoveryで得たURLにも送信先制限を適用し、tokenを別originへ送信しない。
|
|
11
13
|
const discovery = await oauth.discoveryRequest(issuer, { ...options(), algorithm: 'oauth2' })
|
|
12
14
|
const server = await oauth.processDiscoveryResponse(issuer, discovery)
|
|
15
|
+
|
|
13
16
|
for (const key of [ 'authorization_endpoint', 'token_endpoint', 'revocation_endpoint', 'jwks_uri' ]) {
|
|
14
17
|
const endpoint = new URL(server[key])
|
|
15
18
|
if (endpoint.origin !== issuer.origin || endpoint.username || endpoint.password || endpoint.hash) {
|
|
16
19
|
throw new Error('IDP returned an unexpected OAuth endpoint.')
|
|
17
20
|
}
|
|
18
21
|
}
|
|
22
|
+
|
|
19
23
|
if (!server.code_challenge_methods_supported?.includes('S256')) throw new Error('IDP does not support S256 PKCE.')
|
|
24
|
+
|
|
20
25
|
const client = { client_id: config.clientId }
|
|
21
26
|
const authentication = oauth.None()
|
|
22
27
|
|
|
23
28
|
const authorize = async (redirectUri, state, verifier) => {
|
|
24
29
|
const url = new URL(server.authorization_endpoint)
|
|
30
|
+
|
|
25
31
|
url.search = new URLSearchParams({
|
|
26
32
|
client_id: config.clientId,
|
|
27
33
|
redirect_uri: redirectUri,
|
|
@@ -32,6 +38,7 @@ export const createOAuth = async (config, { signal, fetch: fetcher = globalThis.
|
|
|
32
38
|
code_challenge: await oauth.calculatePKCECodeChallenge(verifier),
|
|
33
39
|
code_challenge_method: 'S256',
|
|
34
40
|
}).toString()
|
|
41
|
+
|
|
35
42
|
return url.href
|
|
36
43
|
}
|
|
37
44
|
|
|
@@ -40,17 +47,33 @@ export const createOAuth = async (config, { signal, fetch: fetcher = globalThis.
|
|
|
40
47
|
const request = new Request(config.resource, { headers: { authorization: `Bearer ${accessToken}` } })
|
|
41
48
|
const claims = await oauth.validateJwtAccessToken(server, request, config.resource, options())
|
|
42
49
|
const scopes = typeof claims.scope === 'string' ? claims.scope.split(' ') : []
|
|
50
|
+
|
|
43
51
|
if (claims.client_id !== config.clientId || !config.scopes.every(scope => scopes.includes(scope))) {
|
|
44
52
|
throw new Error('IDP returned a token for an unexpected client or scope.')
|
|
45
53
|
}
|
|
46
|
-
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
userId: claims.sub,
|
|
57
|
+
personalOrganizationId: typeof claims.personal_organization_id === 'string' ? claims.personal_organization_id : undefined,
|
|
58
|
+
username: typeof claims.preferred_username === 'string' ? claims.preferred_username : undefined,
|
|
59
|
+
email: typeof claims.email === 'string' ? claims.email : undefined,
|
|
60
|
+
issuer: claims.iss,
|
|
61
|
+
resource: config.resource,
|
|
62
|
+
scopes,
|
|
63
|
+
expiresAt: claims.exp * 1000,
|
|
64
|
+
}
|
|
47
65
|
}
|
|
48
66
|
|
|
49
67
|
const session = async (tokens) => {
|
|
50
|
-
|
|
68
|
+
// npm配布CLIにはprivateな@plime-inc/utilsを持ち込まない。
|
|
69
|
+
// eslint-disable-next-line plime-utils/prefer-shared-predicate
|
|
70
|
+
const validExpiry = Number.isFinite(tokens.expires_in) && tokens.expires_in > 0
|
|
71
|
+
if (!tokens.refresh_token || !validExpiry || tokens.token_type !== 'bearer') {
|
|
51
72
|
throw new Error('IDP did not return the required login tokens.')
|
|
52
73
|
}
|
|
74
|
+
|
|
53
75
|
const user = await identity(tokens.access_token)
|
|
76
|
+
|
|
54
77
|
return {
|
|
55
78
|
version: 1,
|
|
56
79
|
issuer: config.issuer,
|
|
@@ -63,27 +86,40 @@ export const createOAuth = async (config, { signal, fetch: fetcher = globalThis.
|
|
|
63
86
|
|
|
64
87
|
const exchange = async (callback, redirectUri, state, verifier) => {
|
|
65
88
|
const parameters = oauth.validateAuthResponse(server, client, callback, state)
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
89
|
+
|
|
90
|
+
const exchangeOptions = {
|
|
91
|
+
...options(),
|
|
92
|
+
additionalParameters: { resource: config.resource },
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const request = [ server, client, authentication, parameters, redirectUri, verifier, exchangeOptions ]
|
|
96
|
+
const response = await oauth.authorizationCodeGrantRequest(...request)
|
|
97
|
+
|
|
69
98
|
return session(await oauth.processAuthorizationCodeResponse(server, client, response))
|
|
70
99
|
}
|
|
71
100
|
|
|
72
101
|
const refresh = async (refreshToken) => {
|
|
73
102
|
const response = await oauth.refreshTokenGrantRequest(server, client, authentication, refreshToken, {
|
|
74
|
-
...options(),
|
|
103
|
+
...options(),
|
|
104
|
+
additionalParameters: { resource: config.resource },
|
|
75
105
|
})
|
|
106
|
+
|
|
76
107
|
return session(await oauth.processRefreshTokenResponse(server, client, response))
|
|
77
108
|
}
|
|
78
109
|
|
|
79
110
|
const revoke = async (refreshToken) => {
|
|
80
111
|
const response = await oauth.revocationRequest(server, client, authentication, refreshToken, {
|
|
81
|
-
...options(),
|
|
112
|
+
...options(),
|
|
113
|
+
additionalParameters: { token_type_hint: 'refresh_token' },
|
|
82
114
|
})
|
|
115
|
+
|
|
83
116
|
await oauth.processRevocationResponse(response)
|
|
84
117
|
}
|
|
85
118
|
|
|
86
119
|
return { authorize, exchange, refresh, revoke, identity }
|
|
87
120
|
}
|
|
88
121
|
|
|
89
|
-
export const createLoginProof = () => ({
|
|
122
|
+
export const createLoginProof = () => ({
|
|
123
|
+
state: oauth.generateRandomState(),
|
|
124
|
+
verifier: oauth.generateRandomCodeVerifier(),
|
|
125
|
+
})
|