omp-figma-remote-auth 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/LICENSE +22 -0
- package/README.md +109 -0
- package/README.zh-CN.md +109 -0
- package/index.ts +120 -0
- package/package.json +53 -0
- package/src/args.ts +108 -0
- package/src/config.ts +122 -0
- package/src/oauth.ts +324 -0
- package/src/storage.ts +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DianP
|
|
4
|
+
Copyright (c) 2026 omp-figma-remote-auth contributors
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# omp-figma-remote-auth
|
|
2
|
+
|
|
3
|
+
[简体中文](README.zh-CN.md)
|
|
4
|
+
|
|
5
|
+
An unofficial [Oh My Pi (OMP)](https://github.com/can1357/oh-my-pi) plugin for authenticating with the official Figma Remote MCP server at `https://mcp.figma.com/mcp`. It configures the `figma` server and performs browser OAuth login; OMP provides native MCP transport, tool discovery, and token refresh. This project is not affiliated with or endorsed by Figma or OpenAI.
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
- OMP **18.1.17 is the currently verified version**. Use 18.1.17+; compatibility with future releases is not guaranteed.
|
|
10
|
+
- Git, a Figma account with access to the intended files, and a browser on the same machine as OMP for the local OAuth callback. Figma Desktop is not required.
|
|
11
|
+
- Zero runtime package dependencies. No `pi-mcp-adapter`, `npm install`, or build step is needed. Development tests require Node.js 22.6.0+ and npm.
|
|
12
|
+
|
|
13
|
+
## Install and connect
|
|
14
|
+
|
|
15
|
+
Run this command in your **terminal (CLI)** to install from npm:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
omp install omp-figma-remote-auth
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Restart OMP, or enter `/reload-plugins` in an existing OMP session. No source checkout or separate `npm install -g` is required.
|
|
22
|
+
|
|
23
|
+
To develop the plugin or install from source instead:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
git clone https://github.com/picasuo/omp-figma-remote-auth.git
|
|
27
|
+
omp plugin link ./omp-figma-remote-auth
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
For a source installation, keep the cloned directory in place: OMP links to it. Restart OMP or run `/reload-plugins` after linking.
|
|
31
|
+
|
|
32
|
+
The following are **slash commands inside OMP's interactive interface (TUI)**, not shell commands. Start OMP with `omp` if needed, then run:
|
|
33
|
+
|
|
34
|
+
```text
|
|
35
|
+
/figma-remote-auth setup
|
|
36
|
+
/figma-remote-auth login
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`setup` configures the server without logging in. It is optional here because `login` runs setup automatically.
|
|
40
|
+
|
|
41
|
+
Open the displayed authorization URL manually in a browser on the same machine. With the default settings, Figma's consent page shows **Codex** as the application name; see the authentication explanation below. Approve access, keep OMP running for the callback to `http://127.0.0.1:<port>/callback`, and return to OMP to confirm that authorization was saved. The browser receiving the code alone does not confirm that token exchange succeeded.
|
|
42
|
+
|
|
43
|
+
After OMP confirms success, run these TUI commands:
|
|
44
|
+
|
|
45
|
+
```text
|
|
46
|
+
/mcp reload
|
|
47
|
+
/mcp test figma
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
You can then ask OMP to use Figma tools with a Figma file or node URL you can access.
|
|
51
|
+
|
|
52
|
+
## Commands
|
|
53
|
+
|
|
54
|
+
All commands in this table run in the OMP TUI.
|
|
55
|
+
|
|
56
|
+
| Command | Purpose |
|
|
57
|
+
| --- | --- |
|
|
58
|
+
| `/figma-remote-auth help` | Show usage; also the default with no subcommand. |
|
|
59
|
+
| `/figma-remote-auth setup` | Add or merge the native `figma` HTTP MCP configuration. |
|
|
60
|
+
| `/figma-remote-auth login` | Run setup, dynamically register a client, and display the browser authorization link. |
|
|
61
|
+
| `/figma-remote-auth status` | Check local configuration, credential presence/expiry, and any active operation; this is not a live connection test. |
|
|
62
|
+
| `/figma-remote-auth logout` | Delete only this plugin's credential for the active profile; keep the MCP configuration. |
|
|
63
|
+
| `/figma-remote-auth cancel` | Cancel the pending authorization and close its callback listener. |
|
|
64
|
+
|
|
65
|
+
`login` accepts `--client-name` (default `Codex`, 1–128 printable characters, not blank) and `--port` (default `0`, which lets the OS choose an available port; valid range 0–65535). For example:
|
|
66
|
+
|
|
67
|
+
```text
|
|
68
|
+
/figma-remote-auth login --client-name Codex --port 19876
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Quote names containing spaces. A different client name may be rejected by Figma. Login times out after 10 minutes; `cancel` or exiting OMP also stops the wait. Run `/mcp reload` after setup, login, or logout so OMP picks up the change.
|
|
72
|
+
|
|
73
|
+
## Authentication, profiles, and limits
|
|
74
|
+
|
|
75
|
+
Each login sends `client_name: Codex` by default to Figma's dynamic client registration endpoint and receives a **new `client_id`**. It then uses the OAuth authorization code flow with PKCE S256 and state validation. It does not reuse or steal an existing Codex client ID, secret, or account token. The display name does not make this an official Codex integration. This is a compatibility workaround for Figma's client acceptance policy; Figma may change registration rules or endpoints and stop accepting it. See Figma's [client access policy](https://developers.figma.com/docs/figma-mcp-server/rate-limits-access/#which-mcp-clients-are-supported).
|
|
76
|
+
|
|
77
|
+
Setup writes to the **active OMP agent directory's `mcp.json`** (normally `~/.omp/agent/mcp.json`). With `omp --profile <name>`, perform setup, login, status, and logout in that same profile. Credential IDs are derived from the active agent directory and Figma endpoint, so authentication is not automatically shared between profiles. The plugin does not write project-local MCP configuration.
|
|
78
|
+
|
|
79
|
+
Access/refresh tokens and client registration data are saved through **OMP's native AuthStorage**. The `mcp.json` entry contains the server URL, transport type, and an OAuth `credentialId` reference, not the tokens. OMP handles subsequent token refresh.
|
|
80
|
+
|
|
81
|
+
Figma's [official rate limits and access documentation](https://developers.figma.com/docs/figma-mcp-server/rate-limits-access/) currently gives Starter users **up to 20 calls per month for tools that read data from Figma**, with some tools exempt. Limits depend on plan and seat, can change, and do not replace file permissions. This plugin does not increase or bypass quotas.
|
|
82
|
+
|
|
83
|
+
## Troubleshooting
|
|
84
|
+
|
|
85
|
+
- **Unknown slash command:** check `omp plugin list` in the terminal, then restart OMP or run `/reload-plugins`. Use `/figma-remote-auth ...` in the TUI; there is no `omp figma-remote-auth` CLI command. `/mcp ...` commands also belong in the TUI.
|
|
86
|
+
- **Existing configuration conflict:** setup refuses to overwrite a conflicting `mcpServers.figma` entry. Back up the active agent directory's `mcp.json`, then inspect that entry for a different URL/type, an `Authorization` header, another `auth` source, or old transport/token options such as `command`, `args`, `env`, or `oauth`. If migrating, remove or rename the obsolete entry after reviewing it, then rerun setup/login. Preserve unrelated servers. Also check project MCP configurations if OMP still resolves a different `figma` server. A credential ownership conflict requires resolving the other authentication source; the plugin will not overwrite it.
|
|
87
|
+
- **Browser callback fails or login stalls:** keep the browser and OMP on the same machine; a browser on your laptop cannot directly reach a remote SSH/container loopback listener. Check local port access, cancel the attempt, and retry with `--port 0` or an available fixed port. A failed login may leave setup in place without a saved credential.
|
|
88
|
+
- **Authentication still fails:** run `/figma-remote-auth status`, log in again with `/figma-remote-auth login`, then `/mcp reload` and `/mcp test figma`. OMP's `/mcp reauth figma` is its own generic OAuth flow; it does **not** invoke this plugin's login or its client-name registration behavior.
|
|
89
|
+
- **Permission or quota errors:** check the authorized Figma account, file access, plan, and seat against the official limits above; logging in again does not add quota.
|
|
90
|
+
|
|
91
|
+
## Logout and uninstall
|
|
92
|
+
|
|
93
|
+
Installing or linking the plugin does not log you in. Uninstalling it does not automatically clear credentials or remove the `figma` MCP configuration.
|
|
94
|
+
|
|
95
|
+
For credential cleanup, run `/figma-remote-auth logout` and `/mcp reload` in each profile you authorized **before uninstalling**. Logout deletes the local credential only; it does **not revoke the server-side authorization at Figma**. To revoke that authorization, remove the corresponding app authorization in your Figma account settings.
|
|
96
|
+
|
|
97
|
+
Uninstall from the terminal:
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
omp plugin uninstall omp-figma-remote-auth
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Restart OMP or run `/reload-plugins`. If you also want to remove the server, delete only its `mcpServers.figma` entry from the relevant `mcp.json` and run `/mcp reload`. You can delete the clone after unlinking/uninstalling it. If already uninstalled, link it again to use logout in the original profile.
|
|
104
|
+
|
|
105
|
+
## Development and credits
|
|
106
|
+
|
|
107
|
+
From the repository directory, run `npm test`. Tests use Node's built-in test runner and need no `npm install`. `npm publish` runs the tests through `prepublishOnly` and publishes to the official npm registry. The package's `files` allowlist includes only the runtime source, documentation, and license.
|
|
108
|
+
|
|
109
|
+
[MIT licensed](LICENSE), with copyright notices for DianP and the omp-figma-remote-auth contributors. Adapted for OMP from [DianP/pi-figma-remote-auth](https://github.com/DianP/pi-figma-remote-auth). Thanks also to [sdaoudi/mcp-auth-helper](https://github.com/sdaoudi/mcp-auth-helper), which the original project referenced for the authentication approach.
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# omp-figma-remote-auth
|
|
2
|
+
|
|
3
|
+
[English](README.md)
|
|
4
|
+
|
|
5
|
+
用于登录 Figma 官方远程 MCP 服务 `https://mcp.figma.com/mcp` 的非官方 [Oh My Pi(OMP)](https://github.com/can1357/oh-my-pi) 插件。它配置 `figma` 服务并完成浏览器 OAuth 登录,由 OMP 原生负责 MCP 传输、工具发现和令牌刷新。本项目与 Figma、OpenAI 无隶属关系,也未获其官方背书。
|
|
6
|
+
|
|
7
|
+
## 使用要求
|
|
8
|
+
|
|
9
|
+
- **OMP 18.1.17 是目前验证过的版本**。建议使用 18.1.17+,但不保证未来版本兼容。
|
|
10
|
+
- Git、拥有目标文件访问权限的 Figma 账号,以及与 OMP 运行在同一台机器上的浏览器,用于接收本地 OAuth 回调。无需 Figma 桌面客户端。
|
|
11
|
+
- 零运行时包依赖,无需 `pi-mcp-adapter`、`npm install` 或构建。开发测试需要 Node.js 22.6.0+ 和 npm。
|
|
12
|
+
|
|
13
|
+
## 安装与连接
|
|
14
|
+
|
|
15
|
+
在**终端(CLI)**中执行以下命令,从 npm 安装:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
omp install omp-figma-remote-auth
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
重启 OMP,或在已有 OMP 会话中输入 `/reload-plugins`。无需克隆源码,也无需额外执行 `npm install -g`。
|
|
22
|
+
|
|
23
|
+
如需开发插件或从源码安装:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
git clone https://github.com/picasuo/omp-figma-remote-auth.git
|
|
27
|
+
omp plugin link ./omp-figma-remote-auth
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
源码安装需要保留克隆目录的位置,OMP 会链接到该目录。链接后重启 OMP 或运行 `/reload-plugins`。
|
|
31
|
+
|
|
32
|
+
以下是 **OMP 交互界面(TUI)中的斜杠命令**,不能直接在 shell 中运行。需要时先用 `omp` 启动 OMP,然后执行:
|
|
33
|
+
|
|
34
|
+
```text
|
|
35
|
+
/figma-remote-auth setup
|
|
36
|
+
/figma-remote-auth login
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`setup` 只配置服务,不登录。此处可以省略,因为 `login` 会自动执行 setup。
|
|
40
|
+
|
|
41
|
+
在同一台机器的浏览器中手动打开输出的授权链接。默认设置下,Figma 授权页显示的应用名称是 **Codex**,原因见下方认证机制说明。确认授权并保持 OMP 运行,浏览器会回调 `http://127.0.0.1:<port>/callback`。返回 OMP 确认凭据已保存;浏览器收到授权码并不代表令牌交换已经成功。
|
|
42
|
+
|
|
43
|
+
OMP 提示成功后,在 TUI 中执行:
|
|
44
|
+
|
|
45
|
+
```text
|
|
46
|
+
/mcp reload
|
|
47
|
+
/mcp test figma
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
随后可向 OMP 提供自己有权限访问的 Figma 文件或节点链接,让它使用 Figma 工具。
|
|
51
|
+
|
|
52
|
+
## 命令
|
|
53
|
+
|
|
54
|
+
下表所有命令均在 OMP TUI 中执行。
|
|
55
|
+
|
|
56
|
+
| 命令 | 用途 |
|
|
57
|
+
| --- | --- |
|
|
58
|
+
| `/figma-remote-auth help` | 查看帮助;不带子命令时也会显示帮助。 |
|
|
59
|
+
| `/figma-remote-auth setup` | 添加或合并原生 `figma` HTTP MCP 配置。 |
|
|
60
|
+
| `/figma-remote-auth login` | 自动 setup、动态注册客户端,并显示浏览器授权链接。 |
|
|
61
|
+
| `/figma-remote-auth status` | 检查本地配置、凭据是否存在及到期时间、当前操作状态;不进行实际连接测试。 |
|
|
62
|
+
| `/figma-remote-auth logout` | 仅删除当前 profile 中属于本插件的凭据,保留 MCP 配置。 |
|
|
63
|
+
| `/figma-remote-auth cancel` | 取消正在等待的授权,关闭回调监听。 |
|
|
64
|
+
|
|
65
|
+
`login` 支持 `--client-name`(默认 `Codex`,1–128 个可打印字符,不能全为空白)与 `--port`(默认 `0`,由操作系统选择可用端口;有效范围为 0–65535)。例如:
|
|
66
|
+
|
|
67
|
+
```text
|
|
68
|
+
/figma-remote-auth login --client-name Codex --port 19876
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
含空格的名称需加引号。Figma 可能拒绝其他客户端名称。登录会在 10 分钟后超时;执行 `cancel` 或退出 OMP 也会结束等待。setup、login 或 logout 后均应运行 `/mcp reload`,使 OMP 加载变更。
|
|
72
|
+
|
|
73
|
+
## 认证机制、profile 与限额
|
|
74
|
+
|
|
75
|
+
每次登录默认向 Figma 动态客户端注册端点发送 `client_name: Codex`,获取一个**新的 `client_id`**,再通过带 PKCE S256 和 state 校验的 OAuth 授权码流程登录。插件不会复用或偷用已有 Codex 的客户端 ID、密钥或账号令牌。显示名称也不代表这是官方 Codex 集成。这是针对 Figma 客户端准入策略的兼容方案;Figma 可能修改注册规则或端点,使其失效。参见 Figma 的[客户端访问策略](https://developers.figma.com/docs/figma-mcp-server/rate-limits-access/#which-mcp-clients-are-supported)。
|
|
76
|
+
|
|
77
|
+
Setup 写入**当前 OMP agent 目录下的 `mcp.json`**(通常为 `~/.omp/agent/mcp.json`)。使用 `omp --profile <name>` 时,应在同一个 profile 中执行 setup、login、status 和 logout。凭据 ID 根据当前 agent 目录和 Figma 端点生成,因此各 profile 不会自动共享登录状态。插件不写入项目级 MCP 配置。
|
|
78
|
+
|
|
79
|
+
访问令牌、刷新令牌及客户端注册信息通过 **OMP 原生 AuthStorage** 保存。`mcp.json` 中仅保存服务 URL、传输类型和 OAuth `credentialId` 引用,不保存令牌。后续令牌刷新由 OMP 处理。
|
|
80
|
+
|
|
81
|
+
Figma 的[官方限额与访问说明](https://developers.figma.com/docs/figma-mcp-server/rate-limits-access/)目前规定:Starter 用户对**从 Figma 读取数据的工具,每月最多调用 20 次**,部分工具不计入该限额。额度取决于套餐和席位,可能变更,且不替代文件访问权限。本插件不会增加或绕过额度。
|
|
82
|
+
|
|
83
|
+
## 常见问题
|
|
84
|
+
|
|
85
|
+
- **找不到斜杠命令:**在终端用 `omp plugin list` 检查插件,再重启 OMP 或运行 `/reload-plugins`。`/figma-remote-auth ...` 应在 TUI 中输入,不存在 `omp figma-remote-auth` CLI 命令。`/mcp ...` 同样属于 TUI 命令。
|
|
86
|
+
- **已有配置冲突:**setup 不会覆盖有冲突的 `mcpServers.figma`。先备份当前 agent 目录的 `mcp.json`,检查该条目是否存在不同的 URL/type、`Authorization` 请求头、其他 `auth` 来源,或 `command`、`args`、`env`、`oauth` 等旧传输或令牌选项。迁移时,确认后删除或重命名旧条目,再执行 setup/login,保留其他服务。如果 OMP 仍加载了另一个 `figma` 服务,还需检查项目级 MCP 配置。若提示凭据归属冲突,应先解决其他认证来源的占用,插件不会覆盖它。
|
|
87
|
+
- **浏览器回调失败或一直等待:**浏览器和 OMP 应运行在同一台机器上;笔记本浏览器无法直接连接远程 SSH 主机或容器的回环监听。检查本地端口访问,取消当前操作后用 `--port 0` 或一个可用的固定端口重试。登录失败后,setup 配置可能已写入,但凭据尚未保存。
|
|
88
|
+
- **仍然认证失败:**运行 `/figma-remote-auth status`,用 `/figma-remote-auth login` 重新登录,然后执行 `/mcp reload` 与 `/mcp test figma`。OMP 的 `/mcp reauth figma` 走其自身通用 OAuth 流程,**不会调用本插件的 login,也不会使用本插件的客户端名称注册逻辑**。
|
|
89
|
+
- **权限或额度错误:**检查授权的 Figma 账号、文件权限、套餐和席位,并参照上方官方限额;重新登录不会增加额度。
|
|
90
|
+
|
|
91
|
+
## 退出登录与卸载
|
|
92
|
+
|
|
93
|
+
安装或链接插件不会自动登录;卸载插件也不会自动清除凭据或删除 `figma` MCP 配置。
|
|
94
|
+
|
|
95
|
+
如需清理凭据,请在**卸载前**进入每个曾授权的 profile,执行 `/figma-remote-auth logout` 和 `/mcp reload`。Logout 仅删除本地凭据,**不会撤销 Figma 服务器端授权**。如需撤销,请在 Figma 账号设置中移除对应应用的授权。
|
|
96
|
+
|
|
97
|
+
在终端执行卸载:
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
omp plugin uninstall omp-figma-remote-auth
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
重启 OMP 或运行 `/reload-plugins`。如需同时删除服务,仅从对应的 `mcp.json` 中删除 `mcpServers.figma` 条目,再运行 `/mcp reload`。解除链接或卸载后可以删除克隆目录。如果已经卸载,可重新链接插件,再进入原 profile 执行 logout。
|
|
104
|
+
|
|
105
|
+
## 开发与致谢
|
|
106
|
+
|
|
107
|
+
在仓库目录执行 `npm test`。测试使用 Node 内置测试运行器,无需 `npm install`。`npm publish` 会通过 `prepublishOnly` 运行测试,并发布到 npm 官方仓库。包的 `files` 白名单仅包含运行源码、文档和许可证。
|
|
108
|
+
|
|
109
|
+
本项目采用 [MIT 许可证](LICENSE),保留 DianP 与 omp-figma-remote-auth contributors 的版权声明。由 [DianP/pi-figma-remote-auth](https://github.com/DianP/pi-figma-remote-auth) 适配到 OMP。同时感谢 [sdaoudi/mcp-auth-helper](https://github.com/sdaoudi/mcp-auth-helper),原项目曾参考其认证思路。
|
package/index.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { COMMAND_NAME, FIGMA_URL, UserError, formatError, helpText, parseArgs } from "./src/args.ts";
|
|
2
|
+
import { configStatus, setupConfig } from "./src/config.ts";
|
|
3
|
+
import { runOAuthFlow } from "./src/oauth.ts";
|
|
4
|
+
import type { FlowOptions, OAuthGrant } from "./src/oauth.ts";
|
|
5
|
+
import { credentialIdFor, credentialStatus, logout, ownedCredential, saveCredential } from "./src/storage.ts";
|
|
6
|
+
import type { AuthStorage } from "./src/storage.ts";
|
|
7
|
+
|
|
8
|
+
/** Minimal structural interface; OMP supplies all runtime services, no SDK dependency. */
|
|
9
|
+
export interface CommandContext {
|
|
10
|
+
signal?: AbortSignal;
|
|
11
|
+
modelRegistry: { authStorage: AuthStorage };
|
|
12
|
+
ui: {
|
|
13
|
+
notify(message: string, level: "info" | "error"): void;
|
|
14
|
+
setStatus?(key: string, message: string | undefined): void;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export interface ExtensionAPI {
|
|
18
|
+
pi: { getAgentDir(): string };
|
|
19
|
+
registerCommand(name: string, command: {
|
|
20
|
+
description: string;
|
|
21
|
+
handler(args: string, ctx: CommandContext): Promise<void>;
|
|
22
|
+
}): void;
|
|
23
|
+
on(event: "session_shutdown", handler: () => void | Promise<void>): void;
|
|
24
|
+
}
|
|
25
|
+
export interface Dependencies {
|
|
26
|
+
runOAuth?: (options: FlowOptions) => Promise<OAuthGrant>;
|
|
27
|
+
}
|
|
28
|
+
// One active mutation across extension instances in this process, including during a reload.
|
|
29
|
+
let active: { controller: AbortController; done: Promise<void> } | undefined;
|
|
30
|
+
|
|
31
|
+
export default function figmaRemoteAuthExtension(pi: ExtensionAPI): void {
|
|
32
|
+
registerExtension(pi);
|
|
33
|
+
}
|
|
34
|
+
export function registerExtension(pi: ExtensionAPI, dependencies: Dependencies = {}): void {
|
|
35
|
+
let shuttingDown = false;
|
|
36
|
+
pi.on("session_shutdown", async () => {
|
|
37
|
+
shuttingDown = true;
|
|
38
|
+
const pending = active;
|
|
39
|
+
pending?.controller.abort();
|
|
40
|
+
await pending?.done;
|
|
41
|
+
});
|
|
42
|
+
pi.registerCommand(COMMAND_NAME, {
|
|
43
|
+
description: "Authenticate official Figma Remote MCP using OMP native transport and refresh",
|
|
44
|
+
handler: async (args, ctx) => {
|
|
45
|
+
let finish: (() => void) | undefined;
|
|
46
|
+
let cancel: (() => void) | undefined;
|
|
47
|
+
let operation: typeof active;
|
|
48
|
+
try {
|
|
49
|
+
const command = parseArgs(args);
|
|
50
|
+
if (command.kind === "help") { ctx.ui.notify(helpText(), "info"); return; }
|
|
51
|
+
if (command.kind === "cancel") {
|
|
52
|
+
const pending = active;
|
|
53
|
+
pending?.controller.abort();
|
|
54
|
+
await pending?.done;
|
|
55
|
+
ctx.ui.notify(pending ? "Figma authorization cancelled." : "No Figma authorization is running.", "info");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (shuttingDown) throw new UserError("OMP is shutting down; authentication is unavailable.");
|
|
59
|
+
const agentDir = pi.pi.getAgentDir();
|
|
60
|
+
const credentialId = credentialIdFor(agentDir);
|
|
61
|
+
const storage = ctx.modelRegistry.authStorage;
|
|
62
|
+
if (command.kind === "status") {
|
|
63
|
+
const config = configStatus(agentDir, credentialId);
|
|
64
|
+
ctx.ui.notify([
|
|
65
|
+
`Figma Remote MCP: ${FIGMA_URL}`,
|
|
66
|
+
`Config: ${config.configured ? "configured" : "setup required"}.`,
|
|
67
|
+
credentialStatus(storage, credentialId),
|
|
68
|
+
`Authorization: ${active ? "operation in progress" : "idle"}.`,
|
|
69
|
+
].join("\n"), "info");
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (active) throw new UserError("A Figma authentication operation is already running. Cancel it before starting another.");
|
|
73
|
+
const controller = new AbortController();
|
|
74
|
+
const done = new Promise<void>(resolve => { finish = resolve; });
|
|
75
|
+
operation = { controller, done };
|
|
76
|
+
active = operation;
|
|
77
|
+
cancel = () => controller.abort();
|
|
78
|
+
ctx.signal?.addEventListener("abort", cancel, { once: true });
|
|
79
|
+
if (ctx.signal?.aborted) cancel();
|
|
80
|
+
if (controller.signal.aborted) throw new UserError("Figma authorization cancelled.");
|
|
81
|
+
if (command.kind === "logout") {
|
|
82
|
+
const removed = await logout(storage, credentialId);
|
|
83
|
+
ctx.ui.notify(`${removed ? "Removed this plugin's Figma credential." : "No credential owned by this plugin was found."} Run /mcp reload.`, "info");
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
// Protect credential ownership before setup changes the configured auth source.
|
|
87
|
+
ownedCredential(storage, credentialId);
|
|
88
|
+
setupConfig(agentDir, credentialId);
|
|
89
|
+
if (command.kind === "setup") {
|
|
90
|
+
ctx.ui.notify("Figma MCP configured. Use /figma-remote-auth login to authorize, then run /mcp reload.", "info");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
ctx.ui.setStatus?.(COMMAND_NAME, "Waiting for Figma authorization");
|
|
94
|
+
const grant = await (dependencies.runOAuth ?? runOAuthFlow)({
|
|
95
|
+
clientName: command.clientName, port: command.port, signal: controller.signal,
|
|
96
|
+
onAuthorizationUrl: (url) => {
|
|
97
|
+
ctx.ui.notify(`Open this URL in your browser to authorize Figma:\n${url}\n\nReturn to OMP when finished. Use /figma-remote-auth cancel to stop waiting.`, "info");
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
if (controller.signal.aborted) throw new UserError("Figma authorization cancelled.");
|
|
101
|
+
// Recheck config and ownership after the browser wait before persisting credentials.
|
|
102
|
+
const config = configStatus(agentDir, credentialId);
|
|
103
|
+
if (!config.configured) throw new UserError("Figma configuration changed during authorization; credentials were not saved.");
|
|
104
|
+
await saveCredential(storage, credentialId, grant);
|
|
105
|
+
ctx.ui.notify("Figma authorization saved. Run /mcp reload.", "info");
|
|
106
|
+
} catch (error) {
|
|
107
|
+
ctx.ui.notify(formatError(error), "error");
|
|
108
|
+
} finally {
|
|
109
|
+
if (cancel) ctx.signal?.removeEventListener("abort", cancel);
|
|
110
|
+
if (operation) {
|
|
111
|
+
operation.controller.abort();
|
|
112
|
+
if (active === operation) active = undefined;
|
|
113
|
+
// Resolve first: UI failures must never strand session_shutdown waiting for cleanup.
|
|
114
|
+
finish?.();
|
|
115
|
+
ctx.ui.setStatus?.(COMMAND_NAME, undefined);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "omp-figma-remote-auth",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "OAuth helper for Figma remote MCP using OMP native transport",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "picasuo",
|
|
8
|
+
"contributors": [
|
|
9
|
+
"DianP (original Pi extension)"
|
|
10
|
+
],
|
|
11
|
+
"homepage": "https://github.com/picasuo/omp-figma-remote-auth#readme",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/picasuo/omp-figma-remote-auth.git"
|
|
15
|
+
},
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/picasuo/omp-figma-remote-auth/issues"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"omp",
|
|
21
|
+
"omp-package",
|
|
22
|
+
"oh-my-pi",
|
|
23
|
+
"figma",
|
|
24
|
+
"mcp",
|
|
25
|
+
"oauth",
|
|
26
|
+
"remote-mcp"
|
|
27
|
+
],
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public",
|
|
30
|
+
"registry": "https://registry.npmjs.org/"
|
|
31
|
+
},
|
|
32
|
+
"main": "./index.ts",
|
|
33
|
+
"exports": "./index.ts",
|
|
34
|
+
"files": [
|
|
35
|
+
"index.ts",
|
|
36
|
+
"src",
|
|
37
|
+
"LICENSE",
|
|
38
|
+
"README.md",
|
|
39
|
+
"README.zh-CN.md"
|
|
40
|
+
],
|
|
41
|
+
"omp": {
|
|
42
|
+
"extensions": [
|
|
43
|
+
"./index.ts"
|
|
44
|
+
]
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"test": "node --experimental-strip-types --test test/*.test.ts",
|
|
48
|
+
"prepublishOnly": "npm test"
|
|
49
|
+
},
|
|
50
|
+
"engines": {
|
|
51
|
+
"node": ">=22.6.0"
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/args.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
export const SERVER_NAME = "figma";
|
|
2
|
+
export const FIGMA_URL = "https://mcp.figma.com/mcp";
|
|
3
|
+
export const COMMAND_NAME = "figma-remote-auth";
|
|
4
|
+
|
|
5
|
+
/** Only errors authored by this extension are safe to show to the user. */
|
|
6
|
+
export class UserError extends Error {
|
|
7
|
+
constructor(message: string) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "FigmaRemoteAuthError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function formatError(error: unknown): string {
|
|
14
|
+
return error instanceof UserError
|
|
15
|
+
? error.message
|
|
16
|
+
: "Figma authentication failed. No credentials were included in this error.";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type Command =
|
|
20
|
+
| { kind: "help" | "setup" | "status" | "logout" | "cancel" }
|
|
21
|
+
| { kind: "login"; clientName: string; port: number };
|
|
22
|
+
|
|
23
|
+
/** Small shell-style tokenizer; no expansion, execution, or environment access. */
|
|
24
|
+
function tokenize(input: string): string[] {
|
|
25
|
+
const words: string[] = [];
|
|
26
|
+
let value = "";
|
|
27
|
+
let quote: string | undefined;
|
|
28
|
+
let started = false;
|
|
29
|
+
let escaped = false;
|
|
30
|
+
for (const char of input) {
|
|
31
|
+
if (escaped) {
|
|
32
|
+
value += char;
|
|
33
|
+
escaped = false;
|
|
34
|
+
started = true;
|
|
35
|
+
} else if (char === "\\" && quote !== "'") {
|
|
36
|
+
escaped = true;
|
|
37
|
+
started = true;
|
|
38
|
+
} else if (quote) {
|
|
39
|
+
if (char === quote) quote = undefined;
|
|
40
|
+
else value += char;
|
|
41
|
+
} else if (char === "'" || char === '"') {
|
|
42
|
+
quote = char;
|
|
43
|
+
started = true;
|
|
44
|
+
} else if (/\s/u.test(char)) {
|
|
45
|
+
if (started) words.push(value);
|
|
46
|
+
value = "";
|
|
47
|
+
started = false;
|
|
48
|
+
} else {
|
|
49
|
+
value += char;
|
|
50
|
+
started = true;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (quote || escaped) throw new UserError("Unclosed quote or escape in arguments.");
|
|
54
|
+
if (started) words.push(value);
|
|
55
|
+
return words;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function parseArgs(input: string): Command {
|
|
59
|
+
const [kind = "help", ...args] = tokenize(input);
|
|
60
|
+
if (kind !== "login") {
|
|
61
|
+
if (!["help", "setup", "status", "logout", "cancel"].includes(kind) || args.length) {
|
|
62
|
+
throw new UserError("Invalid command or arguments. Use /figma-remote-auth help.");
|
|
63
|
+
}
|
|
64
|
+
return { kind: kind as "help" | "setup" | "status" | "logout" | "cancel" };
|
|
65
|
+
}
|
|
66
|
+
let clientName = "Codex";
|
|
67
|
+
let port = 0;
|
|
68
|
+
const seen = new Set<string>();
|
|
69
|
+
for (let i = 0; i < args.length; i += 2) {
|
|
70
|
+
const key = args[i];
|
|
71
|
+
const value = args[i + 1];
|
|
72
|
+
if (!key || !["--client-name", "--port"].includes(key) || seen.has(key)) {
|
|
73
|
+
throw new UserError("Unknown or duplicate login option. Use /figma-remote-auth help.");
|
|
74
|
+
}
|
|
75
|
+
if (value === undefined || value.startsWith("--")) {
|
|
76
|
+
throw new UserError("Login option requires a value.");
|
|
77
|
+
}
|
|
78
|
+
seen.add(key);
|
|
79
|
+
if (key === "--client-name") {
|
|
80
|
+
if (!value.trim() || value.length > 128 || /[\x00-\x1f\x7f]/u.test(value)) {
|
|
81
|
+
throw new UserError("Client name must contain 1–128 printable characters.");
|
|
82
|
+
}
|
|
83
|
+
clientName = value;
|
|
84
|
+
} else {
|
|
85
|
+
if (!/^\d{1,5}$/u.test(value) || Number(value) > 65535) {
|
|
86
|
+
throw new UserError("Port must be an integer from 0 to 65535; 0 chooses a random port.");
|
|
87
|
+
}
|
|
88
|
+
port = Number(value);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { kind: "login", clientName, port };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function helpText(): string {
|
|
95
|
+
return [
|
|
96
|
+
"/figma-remote-auth help",
|
|
97
|
+
"/figma-remote-auth setup",
|
|
98
|
+
"/figma-remote-auth login [--client-name Codex] [--port 0]",
|
|
99
|
+
"/figma-remote-auth status",
|
|
100
|
+
"/figma-remote-auth logout",
|
|
101
|
+
"/figma-remote-auth cancel",
|
|
102
|
+
"",
|
|
103
|
+
`Server: ${SERVER_NAME} (${FIGMA_URL})`,
|
|
104
|
+
"Setup uses the active OMP agent directory's mcp.json.",
|
|
105
|
+
"Login displays a link for you to open manually. Use /figma-remote-auth cancel or exit OMP to stop waiting.",
|
|
106
|
+
"OMP handles MCP transport and token refresh. After setup, login, or logout, run /mcp reload.",
|
|
107
|
+
].join("\n");
|
|
108
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { constants, closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { FIGMA_URL, UserError } from "./args.ts";
|
|
5
|
+
|
|
6
|
+
type JsonObject = Record<string, unknown>;
|
|
7
|
+
function record(value: unknown): value is JsonObject {
|
|
8
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
9
|
+
}
|
|
10
|
+
export interface ConfigState { configured: boolean; path: string }
|
|
11
|
+
|
|
12
|
+
/** Newly created private directories use 0700; normal ancestor links (macOS /tmp) are allowed. */
|
|
13
|
+
function privateDirectory(path: string): void {
|
|
14
|
+
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
15
|
+
const info = lstatSync(path);
|
|
16
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
17
|
+
throw new UserError("OMP config directory must be a real directory, not a symlink.");
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function readConfig(path: string): { raw?: string; config: JsonObject } {
|
|
21
|
+
let fd: number | undefined;
|
|
22
|
+
try {
|
|
23
|
+
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
24
|
+
const raw = readFileSync(fd, "utf8");
|
|
25
|
+
let config: unknown;
|
|
26
|
+
try { config = JSON.parse(raw); }
|
|
27
|
+
catch { throw new UserError("OMP mcp.json must contain valid JSON; it was not changed."); }
|
|
28
|
+
if (!record(config)) throw new UserError("OMP mcp.json must contain a JSON object.");
|
|
29
|
+
return { raw, config };
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { config: {} };
|
|
32
|
+
if (error instanceof UserError) throw error;
|
|
33
|
+
throw new UserError("Unable to read OMP mcp.json safely; it was not changed.");
|
|
34
|
+
} finally {
|
|
35
|
+
if (fd !== undefined) closeSync(fd);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Validate before mutation, preserving all unrelated JSON fields and servers. */
|
|
40
|
+
export function mergeConfig(config: JsonObject, credentialId: string): JsonObject {
|
|
41
|
+
const servers = config.mcpServers;
|
|
42
|
+
if (servers !== undefined && !record(servers)) {
|
|
43
|
+
throw new UserError("mcpServers must be an object; mcp.json was not changed.");
|
|
44
|
+
}
|
|
45
|
+
const entries = servers ?? {};
|
|
46
|
+
const existing = entries.figma;
|
|
47
|
+
if (Object.hasOwn(entries, "figma")) {
|
|
48
|
+
if (!record(existing) || existing.url !== FIGMA_URL || existing.type !== "http") {
|
|
49
|
+
throw new UserError("Existing figma endpoint/type conflicts with official Figma HTTP MCP; mcp.json was not changed.");
|
|
50
|
+
}
|
|
51
|
+
if (existing.headers !== undefined) {
|
|
52
|
+
if (!record(existing.headers) || Object.keys(existing.headers).some(key => key.toLowerCase() === "authorization")) {
|
|
53
|
+
throw new UserError("Existing figma headers conflict with OAuth; mcp.json was not changed.");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (Object.hasOwn(existing, "auth")) {
|
|
57
|
+
const auth = existing.auth;
|
|
58
|
+
if (!record(auth) || auth.type !== "oauth" || auth.credentialId !== credentialId ||
|
|
59
|
+
Object.keys(auth).some(key => !["type", "credentialId"].includes(key))) {
|
|
60
|
+
throw new UserError("Existing figma auth belongs to another source or has conflicting options; mcp.json was not changed.");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Do not silently combine a URL transport with alternate endpoint/credential settings.
|
|
64
|
+
if (["endpoint", "command", "args", "env", "apiKey", "token", "bearerToken", "oauth"].some(key => Object.hasOwn(existing, key))) {
|
|
65
|
+
throw new UserError("Existing figma transport or credential options conflict; mcp.json was not changed.");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
...config,
|
|
70
|
+
mcpServers: {
|
|
71
|
+
...entries,
|
|
72
|
+
figma: { ...(record(existing) ? existing : {}), type: "http", url: FIGMA_URL, auth: { type: "oauth", credentialId } },
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function configStatus(agentDir: string, credentialId: string): ConfigState {
|
|
78
|
+
const path = join(agentDir, "mcp.json");
|
|
79
|
+
const { config } = readConfig(path);
|
|
80
|
+
mergeConfig(config, credentialId); // Status reports conflicts without revealing values.
|
|
81
|
+
const servers = record(config.mcpServers) ? config.mcpServers : {};
|
|
82
|
+
const figma = record(servers.figma) ? servers.figma : {};
|
|
83
|
+
const auth = record(figma.auth) ? figma.auth : {};
|
|
84
|
+
return { configured: auth.credentialId === credentialId, path };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Synchronous lock/read/merge/rename keeps local operations in one uninterrupted turn. */
|
|
88
|
+
export function setupConfig(agentDir: string, credentialId: string): string {
|
|
89
|
+
privateDirectory(agentDir);
|
|
90
|
+
const path = join(agentDir, "mcp.json");
|
|
91
|
+
const lockPath = join(agentDir, ".figma-remote-auth-config.lock");
|
|
92
|
+
let lock: number;
|
|
93
|
+
try { lock = openSync(lockPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); }
|
|
94
|
+
catch { throw new UserError("Figma config is locked by another operation. If OMP crashed, remove .figma-remote-auth-config.lock after verifying no login is active."); }
|
|
95
|
+
let temporary: string | undefined;
|
|
96
|
+
let file: number | undefined;
|
|
97
|
+
try {
|
|
98
|
+
const { raw, config } = readConfig(path);
|
|
99
|
+
const merged = mergeConfig(config, credentialId);
|
|
100
|
+
if (JSON.stringify(config) === JSON.stringify(merged)) return path;
|
|
101
|
+
temporary = join(agentDir, `.mcp.json.${randomBytes(16).toString("hex")}.tmp`);
|
|
102
|
+
file = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
103
|
+
writeFileSync(file, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
|
|
104
|
+
fsyncSync(file);
|
|
105
|
+
closeSync(file);
|
|
106
|
+
file = undefined;
|
|
107
|
+
if (readConfig(path).raw !== raw) throw new UserError("mcp.json changed during setup; retry the command.");
|
|
108
|
+
renameSync(temporary, path);
|
|
109
|
+
temporary = undefined;
|
|
110
|
+
const directory = openSync(agentDir, constants.O_RDONLY);
|
|
111
|
+
try { fsyncSync(directory); } finally { closeSync(directory); }
|
|
112
|
+
return path;
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (error instanceof UserError) throw error;
|
|
115
|
+
throw new UserError("Unable to write OMP mcp.json atomically.");
|
|
116
|
+
} finally {
|
|
117
|
+
if (file !== undefined) closeSync(file);
|
|
118
|
+
if (temporary && existsSync(temporary)) unlinkSync(temporary);
|
|
119
|
+
closeSync(lock);
|
|
120
|
+
unlinkSync(lockPath);
|
|
121
|
+
}
|
|
122
|
+
}
|
package/src/oauth.ts
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import type { Server } from "node:http";
|
|
4
|
+
import { UserError } from "./args.ts";
|
|
5
|
+
|
|
6
|
+
export const DISCOVERY_URL = "https://mcp.figma.com/.well-known/oauth-authorization-server";
|
|
7
|
+
export const ENDPOINTS = Object.freeze({
|
|
8
|
+
issuer: "https://api.figma.com",
|
|
9
|
+
authorizationUrl: "https://www.figma.com/oauth/mcp",
|
|
10
|
+
tokenUrl: "https://api.figma.com/v1/oauth/token",
|
|
11
|
+
registrationUrl: "https://api.figma.com/v1/oauth/mcp/register",
|
|
12
|
+
});
|
|
13
|
+
export const CALLBACK_PATH = "/callback";
|
|
14
|
+
export const AUTH_TIMEOUT_MS = 10 * 60 * 1000;
|
|
15
|
+
export type FetchLike = (url: string, init: RequestInit) => Promise<Response>;
|
|
16
|
+
|
|
17
|
+
export interface RegisteredClient {
|
|
18
|
+
clientId: string;
|
|
19
|
+
clientSecret?: string;
|
|
20
|
+
clientIdIssuedAt?: number;
|
|
21
|
+
clientSecretExpiresAt?: number;
|
|
22
|
+
}
|
|
23
|
+
export interface OAuthGrant extends RegisteredClient {
|
|
24
|
+
access: string;
|
|
25
|
+
refresh: string;
|
|
26
|
+
/** OMP consumes absolute milliseconds, not Unix seconds. */
|
|
27
|
+
expires: number;
|
|
28
|
+
tokenUrl: string;
|
|
29
|
+
authorizationUrl: string;
|
|
30
|
+
scope?: string;
|
|
31
|
+
}
|
|
32
|
+
export interface FlowOptions {
|
|
33
|
+
clientName: string;
|
|
34
|
+
port: number;
|
|
35
|
+
signal?: AbortSignal;
|
|
36
|
+
onAuthorizationUrl: (url: string, callbackUrl: string) => void | Promise<void>;
|
|
37
|
+
/** Test seams only. No CLI option can change the remote URLs. */
|
|
38
|
+
fetch?: FetchLike;
|
|
39
|
+
timeoutMs?: number;
|
|
40
|
+
now?: () => number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const OAUTH_ERRORS: Record<string, true> = {
|
|
44
|
+
invalid_request: true, invalid_client: true, invalid_grant: true,
|
|
45
|
+
unauthorized_client: true, unsupported_grant_type: true, invalid_scope: true,
|
|
46
|
+
access_denied: true, unsupported_response_type: true, server_error: true,
|
|
47
|
+
temporarily_unavailable: true, invalid_redirect_uri: true,
|
|
48
|
+
invalid_client_metadata: true, invalid_token: true, insufficient_scope: true,
|
|
49
|
+
};
|
|
50
|
+
function oauthError(value: unknown): string {
|
|
51
|
+
return typeof value === "string" && Object.hasOwn(OAUTH_ERRORS, value)
|
|
52
|
+
? value : "oauth_error";
|
|
53
|
+
}
|
|
54
|
+
export function objectValue(value: unknown): Record<string, unknown> {
|
|
55
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
56
|
+
throw new UserError("Invalid OAuth JSON response.");
|
|
57
|
+
}
|
|
58
|
+
return value as Record<string, unknown>;
|
|
59
|
+
}
|
|
60
|
+
function stringValue(value: unknown, field: string): string {
|
|
61
|
+
if (typeof value !== "string" || !value.trim() || value.length > 65536 || /[\x00-\x1f\x7f]/u.test(value)) {
|
|
62
|
+
throw new UserError(`Invalid OAuth field: ${field}.`);
|
|
63
|
+
}
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
function optionalString(value: unknown, field: string): string | undefined {
|
|
67
|
+
return value === undefined ? undefined : stringValue(value, field);
|
|
68
|
+
}
|
|
69
|
+
function optionalTimestamp(value: unknown, field: string): number | undefined {
|
|
70
|
+
if (value === undefined) return undefined;
|
|
71
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
|
72
|
+
throw new UserError(`Invalid OAuth field: ${field}.`);
|
|
73
|
+
}
|
|
74
|
+
return value;
|
|
75
|
+
}
|
|
76
|
+
function aborted(signal: AbortSignal): void {
|
|
77
|
+
if (signal.aborted) throw signal.reason instanceof UserError
|
|
78
|
+
? signal.reason : new UserError("Figma authorization cancelled.");
|
|
79
|
+
}
|
|
80
|
+
/** Also bounds test/injected fetch implementations that ignore AbortSignal. */
|
|
81
|
+
async function abortable<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
82
|
+
let onAbort!: () => void;
|
|
83
|
+
const cancelled = new Promise<never>((_, reject) => {
|
|
84
|
+
onAbort = () => reject(signal.reason instanceof UserError
|
|
85
|
+
? signal.reason : new UserError("Figma authorization cancelled."));
|
|
86
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
87
|
+
if (signal.aborted) onAbort();
|
|
88
|
+
});
|
|
89
|
+
try {
|
|
90
|
+
return await Promise.race([operation, cancelled]);
|
|
91
|
+
} finally {
|
|
92
|
+
signal.removeEventListener("abort", onAbort);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function fetchJson(
|
|
97
|
+
fetcher: FetchLike, url: string, init: RequestInit, signal: AbortSignal,
|
|
98
|
+
): Promise<Record<string, unknown>> {
|
|
99
|
+
aborted(signal);
|
|
100
|
+
let response: Response;
|
|
101
|
+
try {
|
|
102
|
+
response = await abortable(fetcher(url, { ...init, signal, redirect: "error" }), signal);
|
|
103
|
+
} catch {
|
|
104
|
+
aborted(signal);
|
|
105
|
+
throw new UserError("OAuth network request failed (redirects are disabled).");
|
|
106
|
+
}
|
|
107
|
+
let body: unknown;
|
|
108
|
+
try {
|
|
109
|
+
body = await abortable(response.json(), signal);
|
|
110
|
+
} catch {
|
|
111
|
+
aborted(signal);
|
|
112
|
+
throw new UserError(`OAuth HTTP ${response.status}: invalid JSON response.`);
|
|
113
|
+
}
|
|
114
|
+
const data = objectValue(body);
|
|
115
|
+
if (!response.ok || data.error !== undefined) {
|
|
116
|
+
throw new UserError(`OAuth HTTP ${response.status}${data.error === undefined ? "" : `: ${oauthError(data.error)}`}.`);
|
|
117
|
+
}
|
|
118
|
+
return data;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function discoverEndpoints(fetcher: FetchLike, signal: AbortSignal): Promise<typeof ENDPOINTS> {
|
|
122
|
+
const data = await fetchJson(fetcher, DISCOVERY_URL, {}, signal);
|
|
123
|
+
if (data.issuer !== ENDPOINTS.issuer ||
|
|
124
|
+
data.authorization_endpoint !== ENDPOINTS.authorizationUrl ||
|
|
125
|
+
data.token_endpoint !== ENDPOINTS.tokenUrl ||
|
|
126
|
+
data.registration_endpoint !== ENDPOINTS.registrationUrl ||
|
|
127
|
+
!Array.isArray(data.code_challenge_methods_supported) ||
|
|
128
|
+
!data.code_challenge_methods_supported.includes("S256")) {
|
|
129
|
+
throw new UserError("Figma OAuth discovery contains an untrusted endpoint or does not support S256.");
|
|
130
|
+
}
|
|
131
|
+
return ENDPOINTS;
|
|
132
|
+
}
|
|
133
|
+
export async function registerClient(
|
|
134
|
+
fetcher: FetchLike, redirectUri: string, clientName: string, signal: AbortSignal,
|
|
135
|
+
): Promise<RegisteredClient> {
|
|
136
|
+
const data = await fetchJson(fetcher, ENDPOINTS.registrationUrl, {
|
|
137
|
+
method: "POST",
|
|
138
|
+
headers: { "Content-Type": "application/json" },
|
|
139
|
+
body: JSON.stringify({
|
|
140
|
+
redirect_uris: [redirectUri],
|
|
141
|
+
client_name: clientName,
|
|
142
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
143
|
+
response_types: ["code"],
|
|
144
|
+
token_endpoint_auth_method: "none",
|
|
145
|
+
}),
|
|
146
|
+
}, signal);
|
|
147
|
+
return {
|
|
148
|
+
clientId: stringValue(data.client_id, "client_id"),
|
|
149
|
+
clientSecret: optionalString(data.client_secret, "client_secret"),
|
|
150
|
+
clientIdIssuedAt: optionalTimestamp(data.client_id_issued_at, "client_id_issued_at"),
|
|
151
|
+
clientSecretExpiresAt: optionalTimestamp(data.client_secret_expires_at, "client_secret_expires_at"),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
export function generatePkce(): { verifier: string; challenge: string } {
|
|
155
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
156
|
+
return { verifier, challenge: createHash("sha256").update(verifier).digest("base64url") };
|
|
157
|
+
}
|
|
158
|
+
export function parseTokens(data: Record<string, unknown>, now: number): Pick<OAuthGrant, "access" | "refresh" | "expires" | "scope"> {
|
|
159
|
+
const access = stringValue(data.access_token, "access_token");
|
|
160
|
+
const refresh = stringValue(data.refresh_token, "refresh_token (required for OMP refresh)");
|
|
161
|
+
if (typeof data.token_type !== "string" || data.token_type.toLowerCase() !== "bearer") {
|
|
162
|
+
throw new UserError("OAuth token_type must be Bearer.");
|
|
163
|
+
}
|
|
164
|
+
const duration = typeof data.expires_in === "string" && /^\d+$/u.test(data.expires_in)
|
|
165
|
+
? Number(data.expires_in) : data.expires_in;
|
|
166
|
+
if (typeof duration !== "number" || !Number.isSafeInteger(duration) || duration <= 0 ||
|
|
167
|
+
!Number.isSafeInteger(now) || now < 0 ||
|
|
168
|
+
!Number.isSafeInteger(now + duration * 1000) || now + duration * 1000 > 8.64e15) {
|
|
169
|
+
throw new UserError("OAuth expires_in must be a positive, safely representable duration.");
|
|
170
|
+
}
|
|
171
|
+
return { access, refresh, expires: now + duration * 1000, scope: optionalString(data.scope, "scope") };
|
|
172
|
+
}
|
|
173
|
+
export async function exchangeCode(
|
|
174
|
+
fetcher: FetchLike, client: RegisteredClient, redirectUri: string,
|
|
175
|
+
code: string, verifier: string, signal: AbortSignal, now: () => number = Date.now,
|
|
176
|
+
): Promise<OAuthGrant> {
|
|
177
|
+
const body = new URLSearchParams({
|
|
178
|
+
grant_type: "authorization_code", code, redirect_uri: redirectUri,
|
|
179
|
+
client_id: client.clientId, code_verifier: verifier,
|
|
180
|
+
});
|
|
181
|
+
if (client.clientSecret) body.set("client_secret", client.clientSecret);
|
|
182
|
+
const data = await fetchJson(fetcher, ENDPOINTS.tokenUrl, {
|
|
183
|
+
method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
184
|
+
body: body.toString(),
|
|
185
|
+
}, signal);
|
|
186
|
+
return { ...client, ...parseTokens(data, now()), tokenUrl: ENDPOINTS.tokenUrl, authorizationUrl: ENDPOINTS.authorizationUrl };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export interface CallbackListener {
|
|
190
|
+
callbackUrl: string;
|
|
191
|
+
result: Promise<string>;
|
|
192
|
+
close: () => Promise<void>;
|
|
193
|
+
}
|
|
194
|
+
export async function listenForCallback(port: number, state: string, signal: AbortSignal): Promise<CallbackListener> {
|
|
195
|
+
aborted(signal);
|
|
196
|
+
let resolveCode!: (code: string) => void;
|
|
197
|
+
let rejectCode!: (error: unknown) => void;
|
|
198
|
+
let settled = false;
|
|
199
|
+
let closing: Promise<void> | undefined;
|
|
200
|
+
const result = new Promise<string>((resolve, reject) => { resolveCode = resolve; rejectCode = reject; });
|
|
201
|
+
// Discovery/DCR can fail before the caller awaits result. Observe rejection immediately.
|
|
202
|
+
void result.catch(() => {});
|
|
203
|
+
const fail = (error: UserError) => {
|
|
204
|
+
if (!settled) { settled = true; rejectCode(error); }
|
|
205
|
+
};
|
|
206
|
+
let server: Server;
|
|
207
|
+
const close = (): Promise<void> => {
|
|
208
|
+
if (closing) return closing;
|
|
209
|
+
signal.removeEventListener("abort", onAbort);
|
|
210
|
+
fail(new UserError("Figma authorization cancelled."));
|
|
211
|
+
closing = new Promise<void>((resolve) => {
|
|
212
|
+
server.close(() => resolve());
|
|
213
|
+
server.closeAllConnections();
|
|
214
|
+
});
|
|
215
|
+
return closing;
|
|
216
|
+
};
|
|
217
|
+
const onAbort = () => {
|
|
218
|
+
fail(signal.reason instanceof UserError ? signal.reason : new UserError("Figma authorization cancelled."));
|
|
219
|
+
void close();
|
|
220
|
+
};
|
|
221
|
+
server = createServer((req, res) => {
|
|
222
|
+
const reply = (status: number, text: string) => {
|
|
223
|
+
res.writeHead(status, {
|
|
224
|
+
"Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store",
|
|
225
|
+
"Referrer-Policy": "no-referrer", "Connection": "close",
|
|
226
|
+
"Content-Security-Policy": "default-src 'none'",
|
|
227
|
+
});
|
|
228
|
+
res.end(text);
|
|
229
|
+
};
|
|
230
|
+
if (settled) { reply(410, "Authorization already completed."); return; }
|
|
231
|
+
if (req.method !== "GET") { reply(405, "GET required."); return; }
|
|
232
|
+
if (!req.url?.startsWith("/") || req.url.startsWith("//")) { reply(400, "Bad callback."); return; }
|
|
233
|
+
let url: URL;
|
|
234
|
+
try { url = new URL(req.url, "http://127.0.0.1"); }
|
|
235
|
+
catch { reply(400, "Bad callback."); return; }
|
|
236
|
+
if (url.pathname !== CALLBACK_PATH) { reply(404, "Not found."); return; }
|
|
237
|
+
const states = url.searchParams.getAll("state");
|
|
238
|
+
const received = Buffer.from(states[0] ?? "");
|
|
239
|
+
const expected = Buffer.from(state);
|
|
240
|
+
if (states.length !== 1 || received.length !== expected.length || !timingSafeEqual(received, expected)) {
|
|
241
|
+
reply(400, "Invalid OAuth state."); return;
|
|
242
|
+
}
|
|
243
|
+
// Validate state before processing errors: unrelated requests cannot abort login.
|
|
244
|
+
const errors = url.searchParams.getAll("error");
|
|
245
|
+
const codes = url.searchParams.getAll("code");
|
|
246
|
+
const issuers = url.searchParams.getAll("iss");
|
|
247
|
+
if (issuers.length > 1 || (issuers.length === 1 && issuers[0] !== ENDPOINTS.issuer)) {
|
|
248
|
+
reply(400, "Invalid OAuth issuer."); return;
|
|
249
|
+
}
|
|
250
|
+
if (errors.length === 1 && codes.length === 0) {
|
|
251
|
+
reply(400, "Authorization was not granted. Return to OMP.");
|
|
252
|
+
fail(new UserError(`OAuth callback: ${oauthError(errors[0])}.`));
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (errors.length || codes.length !== 1 || !codes[0] || codes[0].length > 65536 || /[\x00-\x1f\x7f]/u.test(codes[0])) {
|
|
256
|
+
reply(400, "Invalid OAuth callback."); return;
|
|
257
|
+
}
|
|
258
|
+
settled = true;
|
|
259
|
+
reply(200, "Authorization code received. Return to OMP to confirm token exchange completed.");
|
|
260
|
+
resolveCode(codes[0]);
|
|
261
|
+
});
|
|
262
|
+
server.requestTimeout = 10_000;
|
|
263
|
+
server.headersTimeout = 10_000;
|
|
264
|
+
server.on("error", () => {
|
|
265
|
+
fail(new UserError("OAuth callback listener failed."));
|
|
266
|
+
void close();
|
|
267
|
+
});
|
|
268
|
+
// Await bind before installing abort cleanup; an abort during bind is checked immediately after.
|
|
269
|
+
try {
|
|
270
|
+
await new Promise<void>((resolve, reject) => {
|
|
271
|
+
const bindError = () => reject(new UserError("Unable to bind OAuth callback to 127.0.0.1."));
|
|
272
|
+
server.once("error", bindError);
|
|
273
|
+
server.listen(port, "127.0.0.1", () => { server.off("error", bindError); resolve(); });
|
|
274
|
+
});
|
|
275
|
+
const address = server.address();
|
|
276
|
+
if (!address || typeof address === "string") throw new UserError("OAuth callback listener has no port.");
|
|
277
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
278
|
+
if (signal.aborted) { onAbort(); aborted(signal); }
|
|
279
|
+
return { callbackUrl: `http://127.0.0.1:${address.port}${CALLBACK_PATH}`, result, close };
|
|
280
|
+
} catch (error) {
|
|
281
|
+
await close();
|
|
282
|
+
throw error;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export async function runOAuthFlow(options: FlowOptions): Promise<OAuthGrant> {
|
|
287
|
+
const controller = new AbortController();
|
|
288
|
+
const cancel = () => controller.abort(new UserError("Figma authorization cancelled."));
|
|
289
|
+
const timeoutMs = options.timeoutMs ?? AUTH_TIMEOUT_MS;
|
|
290
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647) {
|
|
291
|
+
throw new UserError("Invalid OAuth timeout.");
|
|
292
|
+
}
|
|
293
|
+
const timer = setTimeout(() => controller.abort(new UserError("Figma authorization timed out.")), timeoutMs);
|
|
294
|
+
options.signal?.addEventListener("abort", cancel, { once: true });
|
|
295
|
+
if (options.signal?.aborted) cancel();
|
|
296
|
+
const fetcher = options.fetch ?? ((url, init) => fetch(url, init));
|
|
297
|
+
let listener: CallbackListener | undefined;
|
|
298
|
+
try {
|
|
299
|
+
aborted(controller.signal);
|
|
300
|
+
const { verifier, challenge } = generatePkce();
|
|
301
|
+
const state = randomBytes(32).toString("base64url");
|
|
302
|
+
listener = await listenForCallback(options.port, state, controller.signal);
|
|
303
|
+
await discoverEndpoints(fetcher, controller.signal);
|
|
304
|
+
const client = await registerClient(fetcher, listener.callbackUrl, options.clientName, controller.signal);
|
|
305
|
+
const url = new URL(ENDPOINTS.authorizationUrl);
|
|
306
|
+
url.search = new URLSearchParams({
|
|
307
|
+
response_type: "code", client_id: client.clientId, redirect_uri: listener.callbackUrl,
|
|
308
|
+
code_challenge: challenge, code_challenge_method: "S256", state,
|
|
309
|
+
}).toString();
|
|
310
|
+
aborted(controller.signal);
|
|
311
|
+
await abortable(Promise.resolve(options.onAuthorizationUrl(url.href, listener.callbackUrl)), controller.signal);
|
|
312
|
+
const code = await listener.result;
|
|
313
|
+
aborted(controller.signal);
|
|
314
|
+
const grant = await exchangeCode(fetcher, client, listener.callbackUrl, code, verifier, controller.signal, options.now);
|
|
315
|
+
aborted(controller.signal);
|
|
316
|
+
return grant;
|
|
317
|
+
} finally {
|
|
318
|
+
clearTimeout(timer);
|
|
319
|
+
options.signal?.removeEventListener("abort", cancel);
|
|
320
|
+
// Cancel in-flight fetches as well as the listener on every exit path.
|
|
321
|
+
controller.abort(new UserError("Figma authorization cancelled."));
|
|
322
|
+
await listener?.close();
|
|
323
|
+
}
|
|
324
|
+
}
|
package/src/storage.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { FIGMA_URL, UserError } from "./args.ts";
|
|
3
|
+
import { ENDPOINTS } from "./oauth.ts";
|
|
4
|
+
import type { OAuthGrant } from "./oauth.ts";
|
|
5
|
+
|
|
6
|
+
export const OWNER = "omp-figma-remote-auth/v1";
|
|
7
|
+
export interface Credential extends OAuthGrant {
|
|
8
|
+
type: "oauth";
|
|
9
|
+
figmaRemoteAuthOwner: string;
|
|
10
|
+
figmaRemoteAuthCredentialId: string;
|
|
11
|
+
}
|
|
12
|
+
/** The public AuthStorage contract exposed through ctx.modelRegistry.authStorage. */
|
|
13
|
+
export interface AuthStorage {
|
|
14
|
+
get(provider: string): unknown;
|
|
15
|
+
set(provider: string, credential: Credential): Promise<void>;
|
|
16
|
+
remove(provider: string): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
export function credentialIdFor(agentDir: string): string {
|
|
19
|
+
return `mcp_oauth_omp_figma_${createHash("sha256").update(`${agentDir}\n${FIGMA_URL}`).digest("hex").slice(0, 24)}`;
|
|
20
|
+
}
|
|
21
|
+
export function ownedCredential(storage: AuthStorage, credentialId: string): Credential | undefined {
|
|
22
|
+
const value = storage.get(credentialId);
|
|
23
|
+
if (value === undefined || value === null) return undefined;
|
|
24
|
+
if (typeof value !== "object" || Array.isArray(value) ||
|
|
25
|
+
(value as Partial<Credential>).type !== "oauth" ||
|
|
26
|
+
(value as Partial<Credential>).figmaRemoteAuthOwner !== OWNER ||
|
|
27
|
+
(value as Partial<Credential>).figmaRemoteAuthCredentialId !== credentialId) {
|
|
28
|
+
throw new UserError("The Figma credential ID is occupied by another authentication source. It was not changed.");
|
|
29
|
+
}
|
|
30
|
+
return value as Credential;
|
|
31
|
+
}
|
|
32
|
+
export async function saveCredential(storage: AuthStorage, credentialId: string, grant: OAuthGrant): Promise<void> {
|
|
33
|
+
ownedCredential(storage, credentialId);
|
|
34
|
+
// Defense in depth at the persistence boundary, even for callers other than the OAuth runner.
|
|
35
|
+
if (!grant.access || !grant.refresh || !grant.clientId ||
|
|
36
|
+
!Number.isSafeInteger(grant.expires) || grant.expires <= 0 || grant.expires > 8.64e15 ||
|
|
37
|
+
grant.tokenUrl !== ENDPOINTS.tokenUrl || grant.authorizationUrl !== ENDPOINTS.authorizationUrl) {
|
|
38
|
+
throw new UserError("Invalid Figma OAuth credential; nothing was saved.");
|
|
39
|
+
}
|
|
40
|
+
await storage.set(credentialId, {
|
|
41
|
+
...grant, type: "oauth", figmaRemoteAuthOwner: OWNER, figmaRemoteAuthCredentialId: credentialId,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
export async function logout(storage: AuthStorage, credentialId: string): Promise<boolean> {
|
|
45
|
+
if (!ownedCredential(storage, credentialId)) return false;
|
|
46
|
+
await storage.remove(credentialId);
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
export function credentialStatus(storage: AuthStorage, credentialId: string, now = Date.now()): string {
|
|
50
|
+
const credential = ownedCredential(storage, credentialId);
|
|
51
|
+
if (!credential) return "Credential: not logged in.";
|
|
52
|
+
const expiry = credential.expires;
|
|
53
|
+
if (!Number.isSafeInteger(expiry) || expiry <= 0 || expiry > 8.64e15) {
|
|
54
|
+
return "Credential: present, but expiration metadata is invalid; log in again.";
|
|
55
|
+
}
|
|
56
|
+
return `Credential: present; ${expiry > now ? "access token expires" : "access token expired"} ${new Date(expiry).toISOString()}. OMP handles refresh.`;
|
|
57
|
+
}
|