@plime-inc/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -0
- package/bin/plime.js +16 -0
- package/package.json +26 -0
- package/src/cli.js +60 -0
- package/src/commands.js +60 -0
- package/src/config.js +10 -0
- package/src/loopback.js +63 -0
- package/src/oauth.js +89 -0
- package/src/store.js +87 -0
package/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# PLIME CLI
|
|
2
|
+
|
|
3
|
+
Node.js 22.12以降。`plime login` でブラウザを開き、PLIMEアカウントで認証します。
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install -g @plime-inc/cli
|
|
7
|
+
plime login
|
|
8
|
+
plime whoami
|
|
9
|
+
plime logout
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
開発中はworkspace rootから `node packages/cli/plime/bin/plime.js login` で実行できます。パッケージ単体でも動作し、ビルドは不要です。
|
|
13
|
+
|
|
14
|
+
| 操作 | 説明 |
|
|
15
|
+
| --- | --- |
|
|
16
|
+
| `plime login` | ブラウザでログインし、CLIへのアクセスを許可 |
|
|
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出力 |
|
|
20
|
+
| `plime logout` | refresh tokenをIDPで失効させ、端末の認証情報を削除 |
|
|
21
|
+
| `plime logout --local` | オフライン時などに端末の認証情報だけを削除。IDPでは失効しない |
|
|
22
|
+
| `--dev` | ローカルIDPを使用。本番とは別に保存 |
|
|
23
|
+
|
|
24
|
+
標準出力は結果、URLやエラーは標準エラーへ出します。token自体を表示するコマンドはありません。`whoami`は氏名・メールアドレスを要求せず、現在許可されたscopeのユーザーIDを表示します。未ログイン・拒否・通信失敗は終了コード1、中断は130です。
|
|
25
|
+
|
|
26
|
+
認証情報は `$XDG_CONFIG_HOME/plime/<environment>/session.json`(未指定時 `~/.config/plime/<environment>/session.json`)へ保存します。POSIXではディレクトリ700、ファイル600です。暗号化保管やOS Keychainへの保存ではありません。Windowsでは保存先のユーザーACLに依存します。
|
|
27
|
+
|
|
28
|
+
同時実行によるrefresh token再利用を防ぐため、認証情報の操作はファイルlockで直列化します。強制終了でlockが残った場合、他のplimeコマンドが動いていないことを確認して、エラーに表示された `session.lock` を削除してください。通信断でrefresh結果を受け取れなかった場合は、`logout --local` と再ログインで復旧します。
|
|
29
|
+
|
|
30
|
+
ログアウトしてもブラウザのPLIMEセッションは維持されます。発行済みaccess tokenは期限まで有効になり得ます。`whoami`はJWTの有効性を確認するもので、サーバー上の失効を毎回照会するものではありません。
|
|
31
|
+
|
|
32
|
+
利用開始にはIDPのmigration・公式CLIのprovisionと、accountsのログイン・同意画面のデプロイが必要です。`--dev`ではIDP localhost:8787、accounts localhost:3003を起動し、開発DBでもprovisionしてください。
|
|
33
|
+
|
|
34
|
+
AIコマンドとAI API側のOAuth検証・利用認可・課金は未実装です。ログイン成功だけではAI APIの利用開始を意味しません。
|
|
35
|
+
|
|
36
|
+
検証: package rootで `vp test run` と `vp lint`。実Better AuthとのOAuth統合テストはmonorepo内のIDPテストfixtureを使います。
|
package/bin/plime.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { run, errorMessage } from '../src/cli.js'
|
|
3
|
+
|
|
4
|
+
const controller = new AbortController()
|
|
5
|
+
const abort = () => controller.abort()
|
|
6
|
+
process.once('SIGINT', abort)
|
|
7
|
+
process.once('SIGTERM', abort)
|
|
8
|
+
try {
|
|
9
|
+
await run(process.argv.slice(2), { signal: controller.signal })
|
|
10
|
+
} catch (err) {
|
|
11
|
+
process.stderr.write(`plime: ${errorMessage(err)}\n`)
|
|
12
|
+
process.exitCode = controller.signal.aborted ? 130 : 1
|
|
13
|
+
} finally {
|
|
14
|
+
process.removeListener('SIGINT', abort)
|
|
15
|
+
process.removeListener('SIGTERM', abort)
|
|
16
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@plime-inc/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "PLIME command-line client",
|
|
5
|
+
"author": "@plime-kitajima",
|
|
6
|
+
"license": "UNLICENSED",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public",
|
|
9
|
+
"registry": "https://registry.npmjs.org/"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"bin": {
|
|
13
|
+
"plime": "bin/plime.js"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"bin/",
|
|
17
|
+
"src/",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"oauth4webapi": "^3.8.8"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=22.12.0"
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util'
|
|
2
|
+
import { getConfig } from './config.js'
|
|
3
|
+
import { createStore, getStoreDirectory } from './store.js'
|
|
4
|
+
import { createCommands } from './commands.js'
|
|
5
|
+
|
|
6
|
+
const help = `Usage: plime <command> [options]
|
|
7
|
+
|
|
8
|
+
Commands:
|
|
9
|
+
login Sign in using your browser
|
|
10
|
+
logout Revoke the CLI login and remove local tokens
|
|
11
|
+
whoami Show the authenticated user ID
|
|
12
|
+
|
|
13
|
+
Options:
|
|
14
|
+
--dev Use local IDP with separate login storage
|
|
15
|
+
--json Print machine-readable results
|
|
16
|
+
--no-browser Print the login URL without opening a browser
|
|
17
|
+
--local Logout locally without server revocation
|
|
18
|
+
--help, -h Show help
|
|
19
|
+
--version, -v Show version
|
|
20
|
+
`
|
|
21
|
+
|
|
22
|
+
export const run = async (args, { stdout = process.stdout, stderr = process.stderr, signal, env = process.env } = {}) => {
|
|
23
|
+
const { values, positionals } = parseArgs({
|
|
24
|
+
args, allowPositionals: true, strict: true,
|
|
25
|
+
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' },
|
|
29
|
+
},
|
|
30
|
+
})
|
|
31
|
+
if (values.version) {
|
|
32
|
+
const { default: manifest } = await import('../package.json', { with: { type: 'json' } })
|
|
33
|
+
stdout.write(`${manifest.version}\n`)
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
if (values.help || !positionals.length) { stdout.write(help); return }
|
|
37
|
+
const [ command ] = positionals
|
|
38
|
+
if (positionals.length !== 1 || ![ 'login', 'logout', 'whoami' ].includes(command)) throw new Error('Unknown command. Run plime --help.')
|
|
39
|
+
if (values['no-browser'] && command !== 'login') throw new Error('--no-browser is only available for login.')
|
|
40
|
+
if (values.local && command !== 'logout') throw new Error('--local is only available for logout.')
|
|
41
|
+
const config = getConfig(values.dev)
|
|
42
|
+
const store = createStore(getStoreDirectory(config.environment, env))
|
|
43
|
+
const commands = createCommands({ config, store, signal, report: message => stderr.write(`${message}\n`) })
|
|
44
|
+
const result = await commands[command]({ noBrowser: values['no-browser'], local: values.local })
|
|
45
|
+
if (values.json) { stdout.write(`${JSON.stringify(result)}\n`); return }
|
|
46
|
+
if (command === 'logout') {
|
|
47
|
+
stdout.write(values.local ? 'Local login removed. Server tokens were not revoked.\n' : 'Logged out.\n')
|
|
48
|
+
} else {
|
|
49
|
+
stdout.write(`${command === 'login' ? 'Logged in as' : 'User'}: ${result.userId}\nEnvironment: ${config.environment}\n`)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// OAuthの例外cause/bodyにはtokenが含まれ得るため、SDKの例外オブジェクトをログに出さない。
|
|
54
|
+
export const errorMessage = (error) => {
|
|
55
|
+
if (error.error === 'access_denied') return 'Login was denied.'
|
|
56
|
+
if (error.error) return 'OAuth request failed. Check the IDP configuration and try logging in again.'
|
|
57
|
+
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.'
|
|
59
|
+
return error.message || 'Command failed.'
|
|
60
|
+
}
|
package/src/commands.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createOAuth, createLoginProof } from './oauth.js'
|
|
2
|
+
import { createLoopback, openBrowser } from './loopback.js'
|
|
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) {
|
|
32
|
+
try {
|
|
33
|
+
session = await oauth.refresh(session.refreshToken)
|
|
34
|
+
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.')
|
|
39
|
+
}
|
|
40
|
+
throw err
|
|
41
|
+
}
|
|
42
|
+
}
|
|
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)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
await store.remove()
|
|
56
|
+
return { loggedOut: true, localOnly: local }
|
|
57
|
+
}, signal)
|
|
58
|
+
|
|
59
|
+
return { login, whoami, logout }
|
|
60
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// npm配布物はprivate workspace packageへ依存しない。IDPの登録値との一致は統合テストで確認する。
|
|
2
|
+
export const getConfig = (development = false) => ({
|
|
3
|
+
environment: development ? 'development' : 'production',
|
|
4
|
+
issuer: development ? 'http://localhost:8787/api/auth' : 'https://idp.plime.com/api/auth',
|
|
5
|
+
resource: development ? 'http://localhost:8791' : 'https://ai.plime.com',
|
|
6
|
+
clientId: 'plime-cli',
|
|
7
|
+
scopes: [ 'ai:invoke', 'offline_access' ],
|
|
8
|
+
callbackPath: '/oauth/callback',
|
|
9
|
+
development,
|
|
10
|
+
})
|
package/src/loopback.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createServer } from 'node:http'
|
|
2
|
+
import { spawn } from 'node:child_process'
|
|
3
|
+
|
|
4
|
+
// 認可コードだけをloopbackで受け取る。tokenはブラウザに返さない。
|
|
5
|
+
export const createLoopback = async ({ path, state, signal, timeoutMs = 300_000 }) => {
|
|
6
|
+
let resolveCallback
|
|
7
|
+
let rejectCallback
|
|
8
|
+
const callback = new Promise((resolve, reject) => {
|
|
9
|
+
resolveCallback = resolve
|
|
10
|
+
rejectCallback = reject
|
|
11
|
+
})
|
|
12
|
+
// ブラウザ起動前に中断しても未処理rejectionにしない。
|
|
13
|
+
callback.catch(() => {})
|
|
14
|
+
const server = createServer((request, response) => {
|
|
15
|
+
response.setHeader('cache-control', 'no-store')
|
|
16
|
+
response.setHeader('referrer-policy', 'no-referrer')
|
|
17
|
+
response.setHeader('content-type', 'text/plain; charset=utf-8')
|
|
18
|
+
const url = new URL(request.url, redirectUri)
|
|
19
|
+
const expected = new URL(redirectUri)
|
|
20
|
+
if (request.headers.host !== expected.host || url.origin !== expected.origin || request.method !== 'GET' || url.pathname !== path) {
|
|
21
|
+
response.writeHead(404).end('Not found')
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
if (url.searchParams.getAll('state').length !== 1 || url.searchParams.get('state') !== state) {
|
|
25
|
+
response.writeHead(400).end('Invalid login state. Return to your terminal and try again.')
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
response.end('PLIME authorization received. Return to your terminal to check the login result.')
|
|
29
|
+
resolveCallback(url)
|
|
30
|
+
})
|
|
31
|
+
await new Promise((resolve, reject) => {
|
|
32
|
+
server.once('error', reject)
|
|
33
|
+
server.listen(0, '127.0.0.1', resolve)
|
|
34
|
+
})
|
|
35
|
+
const redirectUri = `http://127.0.0.1:${server.address().port}${path}`
|
|
36
|
+
const abort = () => rejectCallback(new Error('Login cancelled.'))
|
|
37
|
+
const timer = setTimeout(() => rejectCallback(new Error('Login timed out. Run plime login again.')), timeoutMs)
|
|
38
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
39
|
+
if (signal?.aborted) abort()
|
|
40
|
+
const close = async () => {
|
|
41
|
+
clearTimeout(timer)
|
|
42
|
+
signal?.removeEventListener('abort', abort)
|
|
43
|
+
await new Promise(resolve => {
|
|
44
|
+
server.close(resolve)
|
|
45
|
+
server.closeAllConnections()
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
return { redirectUri, callback, close }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// OS既定ブラウザをshellを介さず起動する。失敗時は表示済みURLを手動で開ける。
|
|
52
|
+
export const openBrowser = (url) => new Promise((resolve) => {
|
|
53
|
+
const commands = {
|
|
54
|
+
darwin: [ 'open', [ url ] ],
|
|
55
|
+
linux: [ 'xdg-open', [ url ] ],
|
|
56
|
+
win32: [ 'rundll32.exe', [ 'url.dll,FileProtocolHandler', url ] ],
|
|
57
|
+
}
|
|
58
|
+
const command = commands[process.platform]
|
|
59
|
+
if (!command) return resolve(false)
|
|
60
|
+
const child = spawn(command[0], command[1], { stdio: 'ignore', shell: false })
|
|
61
|
+
child.once('error', () => resolve(false))
|
|
62
|
+
child.once('spawn', () => { child.unref(); resolve(true) })
|
|
63
|
+
})
|
package/src/oauth.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import * as oauth from 'oauth4webapi'
|
|
2
|
+
|
|
3
|
+
export const createOAuth = async (config, { signal, fetch: fetcher = globalThis.fetch } = {}) => {
|
|
4
|
+
const issuer = new URL(config.issuer)
|
|
5
|
+
const options = () => ({
|
|
6
|
+
signal: signal ? AbortSignal.any([ signal, AbortSignal.timeout(30_000) ]) : AbortSignal.timeout(30_000),
|
|
7
|
+
[oauth.allowInsecureRequests]: config.development,
|
|
8
|
+
[oauth.customFetch]: fetcher,
|
|
9
|
+
})
|
|
10
|
+
// discoveryで得たURLにも送信先制限を適用し、tokenを別originへ送信しない。
|
|
11
|
+
const discovery = await oauth.discoveryRequest(issuer, { ...options(), algorithm: 'oauth2' })
|
|
12
|
+
const server = await oauth.processDiscoveryResponse(issuer, discovery)
|
|
13
|
+
for (const key of [ 'authorization_endpoint', 'token_endpoint', 'revocation_endpoint', 'jwks_uri' ]) {
|
|
14
|
+
const endpoint = new URL(server[key])
|
|
15
|
+
if (endpoint.origin !== issuer.origin || endpoint.username || endpoint.password || endpoint.hash) {
|
|
16
|
+
throw new Error('IDP returned an unexpected OAuth endpoint.')
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
if (!server.code_challenge_methods_supported?.includes('S256')) throw new Error('IDP does not support S256 PKCE.')
|
|
20
|
+
const client = { client_id: config.clientId }
|
|
21
|
+
const authentication = oauth.None()
|
|
22
|
+
|
|
23
|
+
const authorize = async (redirectUri, state, verifier) => {
|
|
24
|
+
const url = new URL(server.authorization_endpoint)
|
|
25
|
+
url.search = new URLSearchParams({
|
|
26
|
+
client_id: config.clientId,
|
|
27
|
+
redirect_uri: redirectUri,
|
|
28
|
+
response_type: 'code',
|
|
29
|
+
scope: config.scopes.join(' '),
|
|
30
|
+
resource: config.resource,
|
|
31
|
+
state,
|
|
32
|
+
code_challenge: await oauth.calculatePKCECodeChallenge(verifier),
|
|
33
|
+
code_challenge_method: 'S256',
|
|
34
|
+
}).toString()
|
|
35
|
+
return url.href
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const identity = async (accessToken) => {
|
|
39
|
+
// requestはローカル検証用。AI APIへのリクエストは送らない。
|
|
40
|
+
const request = new Request(config.resource, { headers: { authorization: `Bearer ${accessToken}` } })
|
|
41
|
+
const claims = await oauth.validateJwtAccessToken(server, request, config.resource, options())
|
|
42
|
+
const scopes = typeof claims.scope === 'string' ? claims.scope.split(' ') : []
|
|
43
|
+
if (claims.client_id !== config.clientId || !config.scopes.every(scope => scopes.includes(scope))) {
|
|
44
|
+
throw new Error('IDP returned a token for an unexpected client or scope.')
|
|
45
|
+
}
|
|
46
|
+
return { userId: claims.sub, issuer: claims.iss, resource: config.resource, scopes, expiresAt: claims.exp * 1000 }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const session = async (tokens) => {
|
|
50
|
+
if (!tokens.refresh_token || !Number.isFinite(tokens.expires_in) || tokens.expires_in <= 0 || tokens.token_type !== 'bearer') {
|
|
51
|
+
throw new Error('IDP did not return the required login tokens.')
|
|
52
|
+
}
|
|
53
|
+
const user = await identity(tokens.access_token)
|
|
54
|
+
return {
|
|
55
|
+
version: 1,
|
|
56
|
+
issuer: config.issuer,
|
|
57
|
+
resource: config.resource,
|
|
58
|
+
accessToken: tokens.access_token,
|
|
59
|
+
refreshToken: tokens.refresh_token,
|
|
60
|
+
expiresAt: Math.min(user.expiresAt, Date.now() + tokens.expires_in * 1000),
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const exchange = async (callback, redirectUri, state, verifier) => {
|
|
65
|
+
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
|
+
})
|
|
69
|
+
return session(await oauth.processAuthorizationCodeResponse(server, client, response))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const refresh = async (refreshToken) => {
|
|
73
|
+
const response = await oauth.refreshTokenGrantRequest(server, client, authentication, refreshToken, {
|
|
74
|
+
...options(), additionalParameters: { resource: config.resource },
|
|
75
|
+
})
|
|
76
|
+
return session(await oauth.processRefreshTokenResponse(server, client, response))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const revoke = async (refreshToken) => {
|
|
80
|
+
const response = await oauth.revocationRequest(server, client, authentication, refreshToken, {
|
|
81
|
+
...options(), additionalParameters: { token_type_hint: 'refresh_token' },
|
|
82
|
+
})
|
|
83
|
+
await oauth.processRevocationResponse(response)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { authorize, exchange, refresh, revoke, identity }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const createLoginProof = () => ({ state: oauth.generateRandomState(), verifier: oauth.generateRandomCodeVerifier() })
|
package/src/store.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { constants } from 'node:fs'
|
|
2
|
+
import { chmod, lstat, mkdir, open, readFile, rename, unlink } from 'node:fs/promises'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { isAbsolute, join } from 'node:path'
|
|
5
|
+
import { randomUUID } from 'node:crypto'
|
|
6
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
7
|
+
|
|
8
|
+
export const getStoreDirectory = (environment, env = process.env) => {
|
|
9
|
+
const base = env.XDG_CONFIG_HOME || join(homedir(), '.config')
|
|
10
|
+
if (!isAbsolute(base)) throw new Error('XDG_CONFIG_HOME must be an absolute path.')
|
|
11
|
+
return join(base, 'plime', environment)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const createStore = (directory) => {
|
|
15
|
+
const path = join(directory, 'session.json')
|
|
16
|
+
const lockPath = join(directory, 'session.lock')
|
|
17
|
+
|
|
18
|
+
// 所有者だけが参照できるディレクトリに認証情報を書き込む。
|
|
19
|
+
const prepare = async () => {
|
|
20
|
+
await mkdir(directory, { recursive: true, mode: 0o700 })
|
|
21
|
+
const info = await lstat(directory)
|
|
22
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('Invalid PLIME config directory.')
|
|
23
|
+
await chmod(directory, 0o700)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const read = async () => {
|
|
27
|
+
try {
|
|
28
|
+
const info = await lstat(path)
|
|
29
|
+
if (!info.isFile() || info.isSymbolicLink()) throw new Error('Invalid PLIME session file.')
|
|
30
|
+
if (process.platform !== 'win32' && (info.mode & 0o077)) throw new Error('PLIME session file must have permission 600.')
|
|
31
|
+
const data = JSON.parse(await readFile(path, 'utf8'))
|
|
32
|
+
if (data.version !== 1 || typeof data.accessToken !== 'string' || !data.accessToken
|
|
33
|
+
|| typeof data.refreshToken !== 'string' || !data.refreshToken
|
|
34
|
+
|| !Number.isFinite(data.expiresAt) || typeof data.issuer !== 'string' || typeof data.resource !== 'string') {
|
|
35
|
+
throw new Error('Invalid PLIME session file. Run plime logout --local, then plime login.')
|
|
36
|
+
}
|
|
37
|
+
return data
|
|
38
|
+
} catch (err) {
|
|
39
|
+
if (err.code === 'ENOENT') return null
|
|
40
|
+
if (err instanceof SyntaxError) throw new Error('Invalid PLIME session file. Run plime logout --local, then plime login.')
|
|
41
|
+
throw err
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 一時ファイルを同じディレクトリで置換し、途中まで書いたtokenを残さない。
|
|
46
|
+
const write = async (data) => {
|
|
47
|
+
const temporary = join(directory, `session-${randomUUID()}.tmp`)
|
|
48
|
+
const file = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600)
|
|
49
|
+
try {
|
|
50
|
+
await file.writeFile(JSON.stringify(data), 'utf8')
|
|
51
|
+
await file.sync()
|
|
52
|
+
await file.close()
|
|
53
|
+
await rename(temporary, path)
|
|
54
|
+
} finally {
|
|
55
|
+
await file.close()
|
|
56
|
+
await unlink(temporary).catch(err => { if (err.code !== 'ENOENT') throw err })
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const remove = () => unlink(path).catch(err => { if (err.code !== 'ENOENT') throw err })
|
|
61
|
+
|
|
62
|
+
// refresh tokenのローテーションを別プロセスとも直列化する。異常終了のlockは勝手に奪わない。
|
|
63
|
+
const withLock = async (operation, signal) => {
|
|
64
|
+
await prepare()
|
|
65
|
+
const deadline = Date.now() + 10_000
|
|
66
|
+
let lock
|
|
67
|
+
while (!lock) {
|
|
68
|
+
signal?.throwIfAborted()
|
|
69
|
+
try {
|
|
70
|
+
lock = await open(lockPath, 'wx', 0o600)
|
|
71
|
+
} catch (err) {
|
|
72
|
+
if (err.code !== 'EEXIST') throw err
|
|
73
|
+
if (Date.now() >= deadline) throw new Error(`Another plime command holds the session lock. If it crashed, remove ${lockPath} after confirming no plime command is running.`)
|
|
74
|
+
await delay(100, undefined, { signal })
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
await lock.writeFile(String(process.pid))
|
|
79
|
+
return await operation()
|
|
80
|
+
} finally {
|
|
81
|
+
await lock.close()
|
|
82
|
+
await unlink(lockPath)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { read, write, remove, withLock }
|
|
87
|
+
}
|