@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 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を検証してユーザーIDを表示。期限が近いtokenは自動更新 |
19
- | `plime whoami --json` | ユーザーID、issuer、resource、scopes、有効期限をJSON出力 |
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自体を表示するコマンドはありません。`whoami`は氏名・メールアドレスを要求せず、現在許可されたscopeのユーザーIDを表示します。未ログイン・拒否・通信失敗は終了コード1、中断は130です。
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コマンドとAI API側のOAuth検証・利用認可・課金は未実装です。ログイン成功だけではAI APIの利用開始を意味しません。
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.0",
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 user ID
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, allowPositionals: true, strict: true,
28
+ args,
29
+ allowPositionals: true,
30
+ strict: true,
25
31
  options: {
26
- dev: { type: 'boolean' }, json: { type: 'boolean' },
27
- 'no-browser': { type: 'boolean' }, local: { type: 'boolean' },
28
- help: { type: 'boolean', short: 'h' }, version: { type: 'boolean', short: 'v' },
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
- if (values.help || !positionals.length) { stdout.write(help); return }
48
+
49
+ if (values.help || !positionals.length) {
50
+ stdout.write(help)
51
+ return
52
+ }
53
+
37
54
  const [ command ] = positionals
38
- if (positionals.length !== 1 || ![ 'login', 'logout', 'whoami' ].includes(command)) throw new Error('Unknown command. Run plime --help.')
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
- const commands = createCommands({ config, store, signal, report: message => stderr.write(`${message}\n`) })
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
- if (values.json) { stdout.write(`${JSON.stringify(result)}\n`); return }
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
- stdout.write(`${command === 'login' ? 'Logged in as' : 'User'}: ${result.userId}\nEnvironment: ${config.environment}\n`)
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
- if (error.code?.startsWith('OAUTH_')) return 'OAuth response validation failed. Please try logging in again.'
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 = ({ config, store, signal, report, oauthFactory = createOAuth, browser = openBrowser }) => {
5
- const login = ({ noBrowser = false } = {}) => store.withLock(async () => {
6
- if (await store.read()) throw new Error('Already logged in. Run plime logout before switching accounts.')
7
- const oauth = await oauthFactory(config, { signal })
8
- const { state, verifier } = createLoginProof()
9
- const loopback = await createLoopback({ path: config.callbackPath, state, signal })
10
- try {
11
- const url = await oauth.authorize(loopback.redirectUri, state, verifier)
12
- report(`Open this URL to sign in:\n${url}`)
13
- if (!noBrowser) {
14
- // ブラウザ起動コマンドの終了待ちでcallback受信を止めない。
15
- void browser(url).then(opened => { if (!opened) report('Open the URL above in your browser.') })
16
- }
17
- const callback = await loopback.callback
18
- const session = await oauth.exchange(callback, loopback.redirectUri, state, verifier)
19
- await store.write(session)
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
- session = await oauth.refresh(session.refreshToken)
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
- } catch (err) {
36
- if (err.error === 'invalid_grant') {
37
- await store.remove()
38
- throw new Error('Login expired or was revoked. Run plime login again.')
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
- return oauth.identity(session.accessToken)
44
- }, signal)
45
-
46
- const logout = ({ local = false } = {}) => store.withLock(async () => {
47
- if (!local) {
48
- const session = await store.read()
49
- if (session) {
50
- if (session.issuer !== config.issuer || session.resource !== config.resource) throw new Error('Stored login does not match this environment.')
51
- const oauth = await oauthFactory(config, { signal })
52
- await oauth.revoke(session.refreshToken)
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
- return { login, whoami, logout }
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
- return { userId: claims.sub, issuer: claims.iss, resource: config.resource, scopes, expiresAt: claims.exp * 1000 }
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
- if (!tokens.refresh_token || !Number.isFinite(tokens.expires_in) || tokens.expires_in <= 0 || tokens.token_type !== 'bearer') {
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
- const response = await oauth.authorizationCodeGrantRequest(server, client, authentication, parameters, redirectUri, verifier, {
67
- ...options(), additionalParameters: { resource: config.resource },
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(), additionalParameters: { resource: config.resource },
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(), additionalParameters: { token_type_hint: 'refresh_token' },
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 = () => ({ state: oauth.generateRandomState(), verifier: oauth.generateRandomCodeVerifier() })
122
+ export const createLoginProof = () => ({
123
+ state: oauth.generateRandomState(),
124
+ verifier: oauth.generateRandomCodeVerifier(),
125
+ })