strapi-oauth-mcp-manager 1.0.0 → 1.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/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.0
4
+
5
+ Customer sign-in with LINE, for apps that expose MCP tools to their own users.
6
+
7
+ ### Added
8
+
9
+ - Token exchange (RFC 8693) at the token endpoint: a LINE client posts a customer's LINE ID token (`liff.getIDToken()`) and gets a short-lived MCP session. The session runs with the client's mapped admin token.
10
+ - `resolveSubject(authorization)` on the `oauth` service. Tool plugins pass the `Authorization` header their handler received and get the customer's `line:U…` subject, or `null` when there's no verified customer (staff sessions, plain admin tokens and anything invalid). Call it only from MCP tool handlers: it identifies the customer but doesn't authenticate the request.
11
+ - Configuration: `identityProviders.line.channelId` (digits only: the LINE Login channel ID, not the LIFF ID), the test-only `identityProviders.line.verifyUrl` (Strapi warns at startup while it's set), and `endUserAccessTokenTtl` (default 3600 seconds).
12
+ - "Customer sign-in" when adding a client on the **MCP OAuth** page, a Customer column in the sessions list (masked), and token exchange details under Connection details.
13
+ - Discovery lists the token exchange grant when LINE sign-in is configured.
14
+
15
+ ### Changed
16
+
17
+ - New fields: `endUserProvider` on clients (existing clients are staff clients), and `subject` on grants.
18
+ - LINE clients are always public, must keep a mapped admin token, and may have no redirect URI. Only one LINE client can be active at a time, and LINE clients can't use the consent page (`/authorize`).
19
+ - Customer sessions have no refresh token. They expire after `endUserAccessTokenTtl`, and the app exchanges a fresh ID token.
20
+ - Token exchange answers `invalid_grant` only when LINE rejects the ID token. When LINE can't be reached or the LINE client's admin token can't be used, it answers `temporarily_unavailable` (503) with `Retry-After`, and the Strapi log says why.
21
+
22
+ ### Breaking changes
23
+
24
+ None. Staff sign-in, dynamic client registration, refresh rotation and revocation work as before.
25
+
3
26
  ## 1.0.0
4
27
 
5
28
  A rewrite that adds OAuth sign-in to the MCP server built into Strapi 5.47+ (`/mcp`).
package/README.md CHANGED
@@ -174,6 +174,108 @@ These sign-in methods aren't supported:
174
174
 
175
175
  ---
176
176
 
177
+ ## Customer sign-in with LINE
178
+
179
+ Staff connect with their Strapi admin account. Your **customers** can use MCP tools too, from an app inside LINE (a LIFF app or LINE MINI App). The app exchanges the customer's LINE ID token for a short-lived session, and your tools find out who the customer is.
180
+
181
+ ### Set it up
182
+
183
+ 1. **Configure the LINE channel** whose ID tokens you accept. That's the LINE Login or LINE MINI App channel that hosts your app. Use its channel ID (digits only, such as `1657000000`), not the LIFF ID.
184
+
185
+ ```ts
186
+ // config/plugins.ts
187
+ 'strapi-oauth-mcp-manager': {
188
+ config: {
189
+ identityProviders: env('LINE_LOGIN_CHANNEL_ID')
190
+ ? { line: { channelId: env('LINE_LOGIN_CHANNEL_ID') } }
191
+ : {},
192
+ endUserAccessTokenTtl: 3600,
193
+ },
194
+ },
195
+ ```
196
+
197
+ 2. **Create an admin token for customers** under Settings → Admin Tokens. Every customer session runs with its permissions, so grant only what customers may do.
198
+
199
+ > **Content-type permissions aren't scoped to the customer.** The content tools Strapi generates (such as `list_article`) don't call `resolveSubject`, so any content-type permission on this token is available to every customer, unscoped. Grant only the tool-plugin actions customers need.
200
+
201
+ 3. **Add a client** on the **MCP OAuth** page. Choose **Customer sign-in: LINE** and map that token. LINE clients are public, and don't need a redirect URI.
202
+ 4. **Allow your app's origin** in `config/middlewares.ts`, because the app calls Strapi from the browser:
203
+
204
+ ```ts
205
+ {
206
+ name: 'strapi::cors',
207
+ config: {
208
+ origin: ['https://your-app.example.com'],
209
+ methods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
210
+ headers: ['Content-Type', 'Authorization', 'Accept', 'mcp-session-id', 'mcp-protocol-version', 'Last-Event-ID'],
211
+ expose: ['WWW-Authenticate', 'mcp-session-id', 'mcp-protocol-version'],
212
+ },
213
+ },
214
+ ```
215
+
216
+ ### Exchange the ID token in your app
217
+
218
+ The LIFF app needs the `openid` scope for `liff.getIDToken()`.
219
+
220
+ ```js
221
+ async function startCustomerSession() {
222
+ await liff.init({ liffId });
223
+ if (!liff.isLoggedIn()) {
224
+ liff.login(); // LINE sends the customer back to your app, signed in
225
+ return null; // without this, the request below would send subject_token=null
226
+ }
227
+
228
+ const response = await fetch(`${STRAPI_URL}/api/strapi-oauth-mcp-manager/oauth/token`, {
229
+ method: 'POST',
230
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
231
+ body: new URLSearchParams({
232
+ grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
233
+ client_id: LINE_CLIENT_ID,
234
+ subject_token: liff.getIDToken(),
235
+ subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
236
+ resource: `${STRAPI_URL}/mcp`,
237
+ }),
238
+ });
239
+ const { access_token, expires_in } = await response.json();
240
+ // Call ${STRAPI_URL}/mcp with "Authorization: Bearer <access_token>". There is no refresh token:
241
+ // exchange a fresh ID token when the session expires.
242
+ return { access_token, expires_in };
243
+ }
244
+ ```
245
+
246
+ If the token endpoint answers `invalid_grant`, LINE rejected the ID token: sign the customer in again. If it answers `temporarily_unavailable` (HTTP 503), LINE couldn't be reached or the LINE client needs an admin's attention: try again after the `Retry-After` seconds. The Strapi log says which.
247
+
248
+ ### Use the customer's identity in your tools
249
+
250
+ Tool handlers see the caller's original `Authorization` header, so ask this plugin who holds the session:
251
+
252
+ ```ts
253
+ createHandler: (strapi) => async ({ args, extra }) => {
254
+ const subject = await strapi
255
+ .plugin('strapi-oauth-mcp-manager')
256
+ .service('oauth')
257
+ .resolveSubject(extra.requestInfo?.headers?.authorization); // "line:U…" or null
258
+ if (!subject) {
259
+ // No verified customer: a staff session, a plain admin token, or no valid session.
260
+ }
261
+ // ...scope the tool to this customer
262
+ };
263
+ ```
264
+
265
+ `resolveSubject` identifies the customer but doesn't authenticate the request, so call it only from MCP tool handlers, where the middleware has already validated the session. `null` means there's no verified customer, not that the caller is staff.
266
+
267
+ Never take the customer from a tool argument or a custom header. A header set by a middleware doesn't reach tools, and one sent by the caller could be forged. The session token can't be.
268
+
269
+ ### Limits
270
+
271
+ - **No rate limit on the token endpoint.** Put one in front of it (proxy or WAF) for public apps.
272
+ - **A captured ID token can be exchanged until it expires,** like any bearer token. Sessions last at most `endUserAccessTokenTtl`.
273
+ - **To end customer sessions,** deactivate or delete the LINE client, delete or regenerate its admin token, or revoke single sessions on the **MCP OAuth** page. Customer sessions have no "revoke every session approved by" button: their approver is the token's owner, so it would end every customer's session.
274
+ - **`identityProviders.line.verifyUrl`** is only for tests and local development, for example with the mock server in this plugin's repository (`node test/e2e/mock-line-verify.mjs`; it isn't in the npm package). Strapi logs a warning at startup while it's set. Never set it in production.
275
+ - **One LINE client can be active at a time.** Every LINE client accepts ID tokens from the one configured channel, so LINE clients are interchangeable: a customer could use any active one and get its admin token. The plugin refuses to create or re-activate a LINE client while another one is active. Deactivate or delete the other one first.
276
+
277
+ ---
278
+
177
279
  ## How it works
178
280
 
179
281
  ```mermaid
@@ -228,11 +330,18 @@ export default () => ({
228
330
  dynamicClientRegistration: true, // false = only clients added in the admin panel
229
331
  allowUserPermissions: false, // true = also offer "All of my permissions" on the consent page
230
332
  cleanupIntervalMs: 3600000, // how often expired data is removed; 0 turns it off
333
+ identityProviders: {}, // customer sign-in, off by default; e.g. { line: { channelId: '1657000000' } }
334
+ endUserAccessTokenTtl: 3600, // seconds a customer session lasts (there is no refresh token)
231
335
  },
232
336
  },
233
337
  });
234
338
  ```
235
339
 
340
+ `identityProviders.line` turns on [customer sign-in with LINE](#customer-sign-in-with-line):
341
+
342
+ - `channelId` (required): the channel ID of your LINE Login or LINE MINI App channel, digits only. Not the LIFF ID.
343
+ - `verifyUrl` (tests only): where to verify ID tokens instead of LINE's endpoint, such as a local mock server. Strapi logs a warning at startup while it's set.
344
+
236
345
  The **MCP OAuth** admin page requires the **Manage MCP OAuth clients and grants** permission, on the **Plugins** tab of each role under **Settings → Administration Panel → Roles**. Super Admins have it by default.
237
346
 
238
347
  ---
@@ -262,6 +371,7 @@ The **MCP OAuth** admin page requires the **Manage MCP OAuth clients and grants*
262
371
  | Sign-in page links use `http://` or the wrong host | Set `url` in `config/server.ts`, or `proxy: true` behind a reverse proxy. |
263
372
  | "The redirect URI is not registered for this client" | For clients added in the admin panel, the redirect URI must match exactly. `*` matches any run of characters except `/`. |
264
373
  | A client suddenly gets `401` | Its session was revoked, its token was deleted or regenerated, or the user was deactivated. Reconnect. |
374
+ | Customer sign-in answers `temporarily_unavailable` | The Strapi log says why: LINE couldn't be reached, or the LINE client needs a working admin token (the log names the client and the fix). |
265
375
 
266
376
  ---
267
377
 
@@ -289,6 +399,26 @@ End-to-end tests run against a development Strapi app that has the MCP server en
289
399
  BASE=http://localhost:1337 ADMIN_EMAIL=you@example.com ADMIN_PASSWORD=... npm run test:e2e
290
400
  ```
291
401
 
402
+ The suites use the test app's Article type with the fields in `FIELDS` (default `title,body`); set `FIELDS=title,description`, for example, if your Article type has different fields. `SUBJECT` (default `api::article.article`) sets the content type those permissions are for.
403
+
404
+ The LINE suite (`test/e2e/line-exchange.mjs`) starts a mock LINE verify server on port 4545, so keep that port free. It creates its own LINE client, so no other LINE client may be active in the test app. The test app needs customer sign-in pointed at the mock. Have its `config/plugins.ts` read two variables:
405
+
406
+ ```ts
407
+ 'strapi-oauth-mcp-manager': {
408
+ config: {
409
+ identityProviders: env('LINE_LOGIN_CHANNEL_ID')
410
+ ? { line: { channelId: env('LINE_LOGIN_CHANNEL_ID'), verifyUrl: env('LINE_VERIFY_URL') } }
411
+ : {},
412
+ },
413
+ },
414
+ ```
415
+
416
+ and start the app with them set:
417
+
418
+ ```bash
419
+ LINE_LOGIN_CHANNEL_ID=1234567890 LINE_VERIFY_URL=http://localhost:4545/verify npm run develop
420
+ ```
421
+
292
422
  The tests sign in many times, so turn off the admin login rate limit in that app (`rateLimit: { enabled: false }` in `config/admin.ts`). They also create users, tokens and sessions and change the Editor and Author roles temporarily, so don't point them at production.
293
423
 
294
424
  ---
@@ -1,10 +1,10 @@
1
- import { jsx, jsxs } from "react/jsx-runtime";
1
+ import { jsx, jsxs, Fragment } from "react/jsx-runtime";
2
2
  import { useFetchClient, useNotification, Page, Layouts, ConfirmDialog } from "@strapi/strapi/admin";
3
3
  import { Routes, Route } from "react-router-dom";
4
4
  import { useState, useCallback, useEffect } from "react";
5
- import { Alert, Button, Flex, Typography, EmptyStateLayout, Table, Thead, Tr, Th, Tbody, Td, Badge, IconButton, Box, Switch, Dialog, SingleSelect, SingleSelectOption, Modal, Field, TextInput, Textarea, Radio } from "@strapi/design-system";
5
+ import { Alert, Button, Flex, Typography, EmptyStateLayout, Table, Thead, Tr, Th, Tbody, Td, Badge, IconButton, Box, Switch, Dialog, SingleSelect, SingleSelectOption, Modal, Field, TextInput, Radio, Textarea } from "@strapi/design-system";
6
6
  import { Trash, User, WarningCircle, Duplicate, Plus } from "@strapi/icons";
7
- import { P as PLUGIN_ID } from "./index-BrZfQfFg.mjs";
7
+ import { P as PLUGIN_ID } from "./index-B36PlzpY.mjs";
8
8
  const PICK_ON_CONNECT = "pick";
9
9
  const formatDate = (value) => value ? new Date(value).toLocaleString() : "—";
10
10
  const Section = ({ title, subtitle, action, children }) => /* @__PURE__ */ jsxs(Box, { background: "neutral0", hasRadius: true, shadow: "tableShadow", padding: 6, children: [
@@ -41,18 +41,20 @@ const TokenSelect = ({
41
41
  value,
42
42
  current,
43
43
  onChange,
44
- disabled
44
+ disabled,
45
+ allowPickOnConnect = true
45
46
  }) => {
46
47
  const ownsCurrent = current && tokens.some((t) => t.id === current.id);
47
48
  return /* @__PURE__ */ jsxs(
48
49
  SingleSelect,
49
50
  {
50
51
  "aria-label": "Admin token",
51
- value,
52
+ placeholder: "Choose one of your admin tokens",
53
+ value: !allowPickOnConnect && value === PICK_ON_CONNECT ? null : value,
52
54
  disabled,
53
55
  onChange: (next) => onChange(String(next)),
54
56
  children: [
55
- /* @__PURE__ */ jsx(SingleSelectOption, { value: PICK_ON_CONNECT, children: "Picked when connecting" }),
57
+ allowPickOnConnect && /* @__PURE__ */ jsx(SingleSelectOption, { value: PICK_ON_CONNECT, children: "Picked when connecting" }),
56
58
  current && !ownsCurrent && /* @__PURE__ */ jsx(SingleSelectOption, { value: String(current.id), disabled: true, children: current.missing ? "Deleted token" : `${current.name} (owned by ${current.ownerEmail ?? "another admin"})` }),
57
59
  tokens.map((token) => /* @__PURE__ */ jsx(SingleSelectOption, { value: String(token.id), children: token.name }, token.id))
58
60
  ]
@@ -67,6 +69,7 @@ const CreateClientModal = ({ onCreated, tokens }) => {
67
69
  const [redirectUris, setRedirectUris] = useState("");
68
70
  const [confidential, setConfidential] = useState("confidential");
69
71
  const [adminTokenId, setAdminTokenId] = useState(PICK_ON_CONNECT);
72
+ const [signIn, setSignIn] = useState("none");
70
73
  const [created, setCreated] = useState(null);
71
74
  const [submitting, setSubmitting] = useState(false);
72
75
  const reset = () => {
@@ -74,15 +77,18 @@ const CreateClientModal = ({ onCreated, tokens }) => {
74
77
  setRedirectUris("");
75
78
  setConfidential("confidential");
76
79
  setAdminTokenId(PICK_ON_CONNECT);
80
+ setSignIn("none");
77
81
  setCreated(null);
78
82
  };
79
83
  const submit = async () => {
80
84
  setSubmitting(true);
81
85
  try {
86
+ const line = signIn === "line";
82
87
  const { data } = await post(`/${PLUGIN_ID}/clients`, {
83
88
  name,
84
- redirectUris: redirectUris.split("\n").map((uri) => uri.trim()).filter(Boolean),
85
- confidential: confidential === "confidential",
89
+ endUserProvider: signIn,
90
+ redirectUris: line ? [] : redirectUris.split("\n").map((uri) => uri.trim()).filter(Boolean),
91
+ confidential: line ? false : confidential === "confidential",
86
92
  adminTokenId: adminTokenId === PICK_ON_CONNECT ? null : Number(adminTokenId)
87
93
  });
88
94
  setCreated(data.data);
@@ -110,12 +116,20 @@ const CreateClientModal = ({ onCreated, tokens }) => {
110
116
  /* @__PURE__ */ jsx(CopyValue, { label: "Client ID", value: created.clientId }),
111
117
  created.clientSecret && /* @__PURE__ */ jsx(CopyValue, { label: "Client secret", value: created.clientSecret })
112
118
  ] }) : /* @__PURE__ */ jsxs(Flex, { direction: "column", alignItems: "stretch", gap: 4, children: [
113
- /* @__PURE__ */ jsx(Typography, { textColor: "neutral600", children: "Only needed for clients that ask for a client ID and secret, like ChatGPT connectors. Claude and most MCP clients register themselves automatically." }),
119
+ /* @__PURE__ */ jsx(Typography, { textColor: "neutral600", children: "Only needed for clients that ask for a client ID and secret, like ChatGPT connectors, or for a customer app that signs people in with LINE. Claude and most MCP clients register themselves automatically." }),
114
120
  /* @__PURE__ */ jsxs(Field.Root, { name: "name", required: true, children: [
115
121
  /* @__PURE__ */ jsx(Field.Label, { children: "Name" }),
116
- /* @__PURE__ */ jsx(TextInput, { value: name, onChange: (e) => setName(e.target.value), placeholder: "ChatGPT" })
122
+ /* @__PURE__ */ jsx(TextInput, { value: name, onChange: (e) => setName(e.target.value), placeholder: signIn === "line" ? "Customer app" : "ChatGPT" })
117
123
  ] }),
118
- /* @__PURE__ */ jsxs(Field.Root, { name: "redirectUris", required: true, hint: "One per line. * matches any run of characters except /.", children: [
124
+ /* @__PURE__ */ jsxs(Field.Root, { name: "signIn", hint: "LINE: customers sign in to your LINE app, which exchanges their LINE ID token for a session. No consent page is shown.", children: [
125
+ /* @__PURE__ */ jsx(Field.Label, { children: "Customer sign-in" }),
126
+ /* @__PURE__ */ jsxs(Radio.Group, { value: signIn, onValueChange: (value) => setSignIn(value), "aria-label": "Customer sign-in", children: [
127
+ /* @__PURE__ */ jsx(Radio.Item, { value: "none", children: "None: people connect with their Strapi admin account" }),
128
+ /* @__PURE__ */ jsx(Radio.Item, { value: "line", children: "LINE: customers sign in with LINE" })
129
+ ] }),
130
+ /* @__PURE__ */ jsx(Field.Hint, {})
131
+ ] }),
132
+ signIn === "none" && /* @__PURE__ */ jsxs(Field.Root, { name: "redirectUris", required: true, hint: "One per line. * matches any run of characters except /.", children: [
119
133
  /* @__PURE__ */ jsx(Field.Label, { children: "Redirect URIs" }),
120
134
  /* @__PURE__ */ jsx(
121
135
  Textarea,
@@ -131,15 +145,16 @@ const CreateClientModal = ({ onCreated, tokens }) => {
131
145
  Field.Root,
132
146
  {
133
147
  name: "adminToken",
134
- hint: "Map one of your admin tokens so every connection uses it. Only you can then approve this client. Leave it to let each person pick one of their own tokens.",
148
+ required: signIn === "line",
149
+ hint: signIn === "line" ? "Required. Every customer session runs with this token's permissions, so grant it only what customers need." : "Map one of your admin tokens so every connection uses it. Only you can then approve this client. Leave it to let each person pick one of their own tokens.",
135
150
  children: [
136
151
  /* @__PURE__ */ jsx(Field.Label, { children: "Admin token" }),
137
- /* @__PURE__ */ jsx(TokenSelect, { tokens, value: adminTokenId, onChange: setAdminTokenId }),
152
+ /* @__PURE__ */ jsx(TokenSelect, { tokens, value: adminTokenId, onChange: setAdminTokenId, allowPickOnConnect: signIn === "none" }),
138
153
  /* @__PURE__ */ jsx(Field.Hint, {})
139
154
  ]
140
155
  }
141
156
  ),
142
- /* @__PURE__ */ jsxs(Field.Root, { name: "type", children: [
157
+ signIn === "none" && /* @__PURE__ */ jsxs(Field.Root, { name: "type", children: [
143
158
  /* @__PURE__ */ jsx(Field.Label, { children: "Client type" }),
144
159
  /* @__PURE__ */ jsxs(Radio.Group, { value: confidential, onValueChange: setConfidential, "aria-label": "Client type", children: [
145
160
  /* @__PURE__ */ jsx(Radio.Item, { value: "confidential", children: "Confidential (client ID + secret)" }),
@@ -149,7 +164,7 @@ const CreateClientModal = ({ onCreated, tokens }) => {
149
164
  ] }) }),
150
165
  /* @__PURE__ */ jsxs(Modal.Footer, { children: [
151
166
  /* @__PURE__ */ jsx(Modal.Close, { children: /* @__PURE__ */ jsx(Button, { variant: "tertiary", children: created ? "Done" : "Cancel" }) }),
152
- !created && /* @__PURE__ */ jsx(Button, { onClick: submit, loading: submitting, disabled: !name.trim() || !redirectUris.trim(), children: "Create" })
167
+ !created && /* @__PURE__ */ jsx(Button, { onClick: submit, loading: submitting, disabled: !name.trim() || (signIn === "line" ? adminTokenId === PICK_ON_CONNECT : !redirectUris.trim()), children: "Create" })
153
168
  ] })
154
169
  ] })
155
170
  ]
@@ -243,7 +258,16 @@ const HomePage = () => {
243
258
  /* @__PURE__ */ jsx(CopyValue, { label: "Authorization server metadata", value: overview.endpoints.authorizationServerMetadata }),
244
259
  /* @__PURE__ */ jsx(CopyValue, { label: "Authorization endpoint", value: overview.endpoints.authorization }),
245
260
  /* @__PURE__ */ jsx(CopyValue, { label: "Token endpoint", value: overview.endpoints.token }),
246
- /* @__PURE__ */ jsxs(Typography, { variant: "pi", textColor: "neutral600", children: [
261
+ overview.lineSignIn.configured ? /* @__PURE__ */ jsxs(Fragment, { children: [
262
+ /* @__PURE__ */ jsx(CopyValue, { label: "Token exchange grant type", value: overview.tokenExchange.grantType }),
263
+ /* @__PURE__ */ jsx(CopyValue, { label: "Subject token type (LINE ID token)", value: overview.tokenExchange.subjectTokenType }),
264
+ /* @__PURE__ */ jsxs(Typography, { tag: "p", variant: "pi", textColor: "neutral600", children: [
265
+ "LINE sign-in is on for channel ",
266
+ overview.lineSignIn.channelId,
267
+ ". Customer apps post liff.getIDToken() to the token endpoint with a LINE client's ID."
268
+ ] })
269
+ ] }) : clients.some((client) => client.endUserProvider === "line") && /* @__PURE__ */ jsx(Alert, { variant: "warning", title: "LINE sign-in is off", closeLabel: "Close", children: "A LINE client exists, but identityProviders.line.channelId isn't set in this plugin's config." }),
270
+ /* @__PURE__ */ jsxs(Typography, { tag: "p", variant: "pi", textColor: "neutral600", children: [
247
271
  "Dynamic client registration is ",
248
272
  overview.dynamicClientRegistration ? "on" : "off",
249
273
  "."
@@ -254,9 +278,10 @@ const HomePage = () => {
254
278
  {
255
279
  title: "Connected sessions",
256
280
  subtitle: "Each session uses an admin token owned by the person who approved it. Deactivating that person, or deleting or regenerating the token in Settings → Admin Tokens, also ends the session.",
257
- children: grants.length === 0 ? /* @__PURE__ */ jsx(EmptyStateLayout, { content: "No MCP clients are connected yet." }) : /* @__PURE__ */ jsxs(Table, { colCount: 6, rowCount: grants.length + 1, children: [
281
+ children: grants.length === 0 ? /* @__PURE__ */ jsx(EmptyStateLayout, { content: "No MCP clients are connected yet." }) : /* @__PURE__ */ jsxs(Table, { colCount: 7, rowCount: grants.length + 1, children: [
258
282
  /* @__PURE__ */ jsx(Thead, { children: /* @__PURE__ */ jsxs(Tr, { children: [
259
283
  /* @__PURE__ */ jsx(Th, { children: /* @__PURE__ */ jsx(Typography, { variant: "sigma", children: "Client" }) }),
284
+ /* @__PURE__ */ jsx(Th, { children: /* @__PURE__ */ jsx(Typography, { variant: "sigma", children: "Customer" }) }),
260
285
  /* @__PURE__ */ jsx(Th, { children: /* @__PURE__ */ jsx(Typography, { variant: "sigma", children: "Approved by" }) }),
261
286
  /* @__PURE__ */ jsx(Th, { children: /* @__PURE__ */ jsx(Typography, { variant: "sigma", children: "Access" }) }),
262
287
  /* @__PURE__ */ jsx(Th, { children: /* @__PURE__ */ jsx(Typography, { variant: "sigma", children: "Last used" }) }),
@@ -265,6 +290,7 @@ const HomePage = () => {
265
290
  ] }) }),
266
291
  /* @__PURE__ */ jsx(Tbody, { children: grants.map((grant) => /* @__PURE__ */ jsxs(Tr, { children: [
267
292
  /* @__PURE__ */ jsx(Td, { children: /* @__PURE__ */ jsx(Typography, { fontWeight: "semiBold", children: grant.clientName }) }),
293
+ /* @__PURE__ */ jsx(Td, { children: /* @__PURE__ */ jsx(Typography, { children: grant.subject ?? "—" }) }),
268
294
  /* @__PURE__ */ jsx(Td, { children: /* @__PURE__ */ jsxs(Flex, { gap: 2, children: [
269
295
  /* @__PURE__ */ jsx(Typography, { children: grant.userEmail ?? "—" }),
270
296
  !grant.userActive && /* @__PURE__ */ jsx(Badge, { variant: "danger", children: "Inactive" })
@@ -282,7 +308,7 @@ const HomePage = () => {
282
308
  children: /* @__PURE__ */ jsx(Trash, {})
283
309
  }
284
310
  ),
285
- /* @__PURE__ */ jsx(
311
+ !grant.subject && /* @__PURE__ */ jsx(
286
312
  IconButton,
287
313
  {
288
314
  label: `Revoke every session approved by ${grant.userEmail ?? "this user"}`,
@@ -323,15 +349,17 @@ const HomePage = () => {
323
349
  ] }) }),
324
350
  /* @__PURE__ */ jsx(Td, { children: /* @__PURE__ */ jsxs(Flex, { gap: 1, children: [
325
351
  /* @__PURE__ */ jsx(Badge, { variant: client.registrationType === "dynamic" ? "secondary" : "primary", children: client.registrationType === "dynamic" ? "Self-registered" : "Manual" }),
326
- /* @__PURE__ */ jsx(Badge, { children: client.tokenEndpointAuthMethod === "none" ? "Public" : "Confidential" })
352
+ /* @__PURE__ */ jsx(Badge, { children: client.tokenEndpointAuthMethod === "none" ? "Public" : "Confidential" }),
353
+ client.endUserProvider === "line" && /* @__PURE__ */ jsx(Badge, { variant: "success", children: "LINE sign-in" })
327
354
  ] }) }),
328
- /* @__PURE__ */ jsx(Td, { children: /* @__PURE__ */ jsx(Typography, { variant: "pi", style: { wordBreak: "break-all" }, children: client.redirectUris.join(", ") }) }),
355
+ /* @__PURE__ */ jsx(Td, { children: /* @__PURE__ */ jsx(Typography, { variant: "pi", style: { wordBreak: "break-all" }, children: client.redirectUris.length ? client.redirectUris.join(", ") : "—" }) }),
329
356
  /* @__PURE__ */ jsx(Td, { children: /* @__PURE__ */ jsx(Box, { minWidth: "220px", children: /* @__PURE__ */ jsx(
330
357
  TokenSelect,
331
358
  {
332
359
  tokens,
333
360
  current: client.adminToken,
334
361
  value: client.adminToken ? String(client.adminToken.id) : PICK_ON_CONNECT,
362
+ allowPickOnConnect: client.endUserProvider !== "line",
335
363
  onChange: (next) => run(
336
364
  () => put(`/${PLUGIN_ID}/clients/${client.id}`, {
337
365
  adminTokenId: next === PICK_ON_CONNECT ? null : Number(next)
@@ -6,7 +6,7 @@ const reactRouterDom = require("react-router-dom");
6
6
  const react = require("react");
7
7
  const designSystem = require("@strapi/design-system");
8
8
  const icons = require("@strapi/icons");
9
- const index = require("./index-BPOlIxN_.js");
9
+ const index = require("./index-dzfso1OG.js");
10
10
  const PICK_ON_CONNECT = "pick";
11
11
  const formatDate = (value) => value ? new Date(value).toLocaleString() : "—";
12
12
  const Section = ({ title, subtitle, action, children }) => /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Box, { background: "neutral0", hasRadius: true, shadow: "tableShadow", padding: 6, children: [
@@ -43,18 +43,20 @@ const TokenSelect = ({
43
43
  value,
44
44
  current,
45
45
  onChange,
46
- disabled
46
+ disabled,
47
+ allowPickOnConnect = true
47
48
  }) => {
48
49
  const ownsCurrent = current && tokens.some((t) => t.id === current.id);
49
50
  return /* @__PURE__ */ jsxRuntime.jsxs(
50
51
  designSystem.SingleSelect,
51
52
  {
52
53
  "aria-label": "Admin token",
53
- value,
54
+ placeholder: "Choose one of your admin tokens",
55
+ value: !allowPickOnConnect && value === PICK_ON_CONNECT ? null : value,
54
56
  disabled,
55
57
  onChange: (next) => onChange(String(next)),
56
58
  children: [
57
- /* @__PURE__ */ jsxRuntime.jsx(designSystem.SingleSelectOption, { value: PICK_ON_CONNECT, children: "Picked when connecting" }),
59
+ allowPickOnConnect && /* @__PURE__ */ jsxRuntime.jsx(designSystem.SingleSelectOption, { value: PICK_ON_CONNECT, children: "Picked when connecting" }),
58
60
  current && !ownsCurrent && /* @__PURE__ */ jsxRuntime.jsx(designSystem.SingleSelectOption, { value: String(current.id), disabled: true, children: current.missing ? "Deleted token" : `${current.name} (owned by ${current.ownerEmail ?? "another admin"})` }),
59
61
  tokens.map((token) => /* @__PURE__ */ jsxRuntime.jsx(designSystem.SingleSelectOption, { value: String(token.id), children: token.name }, token.id))
60
62
  ]
@@ -69,6 +71,7 @@ const CreateClientModal = ({ onCreated, tokens }) => {
69
71
  const [redirectUris, setRedirectUris] = react.useState("");
70
72
  const [confidential, setConfidential] = react.useState("confidential");
71
73
  const [adminTokenId, setAdminTokenId] = react.useState(PICK_ON_CONNECT);
74
+ const [signIn, setSignIn] = react.useState("none");
72
75
  const [created, setCreated] = react.useState(null);
73
76
  const [submitting, setSubmitting] = react.useState(false);
74
77
  const reset = () => {
@@ -76,15 +79,18 @@ const CreateClientModal = ({ onCreated, tokens }) => {
76
79
  setRedirectUris("");
77
80
  setConfidential("confidential");
78
81
  setAdminTokenId(PICK_ON_CONNECT);
82
+ setSignIn("none");
79
83
  setCreated(null);
80
84
  };
81
85
  const submit = async () => {
82
86
  setSubmitting(true);
83
87
  try {
88
+ const line = signIn === "line";
84
89
  const { data } = await post(`/${index.PLUGIN_ID}/clients`, {
85
90
  name,
86
- redirectUris: redirectUris.split("\n").map((uri) => uri.trim()).filter(Boolean),
87
- confidential: confidential === "confidential",
91
+ endUserProvider: signIn,
92
+ redirectUris: line ? [] : redirectUris.split("\n").map((uri) => uri.trim()).filter(Boolean),
93
+ confidential: line ? false : confidential === "confidential",
88
94
  adminTokenId: adminTokenId === PICK_ON_CONNECT ? null : Number(adminTokenId)
89
95
  });
90
96
  setCreated(data.data);
@@ -112,12 +118,20 @@ const CreateClientModal = ({ onCreated, tokens }) => {
112
118
  /* @__PURE__ */ jsxRuntime.jsx(CopyValue, { label: "Client ID", value: created.clientId }),
113
119
  created.clientSecret && /* @__PURE__ */ jsxRuntime.jsx(CopyValue, { label: "Client secret", value: created.clientSecret })
114
120
  ] }) : /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { direction: "column", alignItems: "stretch", gap: 4, children: [
115
- /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { textColor: "neutral600", children: "Only needed for clients that ask for a client ID and secret, like ChatGPT connectors. Claude and most MCP clients register themselves automatically." }),
121
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { textColor: "neutral600", children: "Only needed for clients that ask for a client ID and secret, like ChatGPT connectors, or for a customer app that signs people in with LINE. Claude and most MCP clients register themselves automatically." }),
116
122
  /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Field.Root, { name: "name", required: true, children: [
117
123
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.Field.Label, { children: "Name" }),
118
- /* @__PURE__ */ jsxRuntime.jsx(designSystem.TextInput, { value: name, onChange: (e) => setName(e.target.value), placeholder: "ChatGPT" })
124
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.TextInput, { value: name, onChange: (e) => setName(e.target.value), placeholder: signIn === "line" ? "Customer app" : "ChatGPT" })
119
125
  ] }),
120
- /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Field.Root, { name: "redirectUris", required: true, hint: "One per line. * matches any run of characters except /.", children: [
126
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Field.Root, { name: "signIn", hint: "LINE: customers sign in to your LINE app, which exchanges their LINE ID token for a session. No consent page is shown.", children: [
127
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Field.Label, { children: "Customer sign-in" }),
128
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Radio.Group, { value: signIn, onValueChange: (value) => setSignIn(value), "aria-label": "Customer sign-in", children: [
129
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Radio.Item, { value: "none", children: "None: people connect with their Strapi admin account" }),
130
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Radio.Item, { value: "line", children: "LINE: customers sign in with LINE" })
131
+ ] }),
132
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Field.Hint, {})
133
+ ] }),
134
+ signIn === "none" && /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Field.Root, { name: "redirectUris", required: true, hint: "One per line. * matches any run of characters except /.", children: [
121
135
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.Field.Label, { children: "Redirect URIs" }),
122
136
  /* @__PURE__ */ jsxRuntime.jsx(
123
137
  designSystem.Textarea,
@@ -133,15 +147,16 @@ const CreateClientModal = ({ onCreated, tokens }) => {
133
147
  designSystem.Field.Root,
134
148
  {
135
149
  name: "adminToken",
136
- hint: "Map one of your admin tokens so every connection uses it. Only you can then approve this client. Leave it to let each person pick one of their own tokens.",
150
+ required: signIn === "line",
151
+ hint: signIn === "line" ? "Required. Every customer session runs with this token's permissions, so grant it only what customers need." : "Map one of your admin tokens so every connection uses it. Only you can then approve this client. Leave it to let each person pick one of their own tokens.",
137
152
  children: [
138
153
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.Field.Label, { children: "Admin token" }),
139
- /* @__PURE__ */ jsxRuntime.jsx(TokenSelect, { tokens, value: adminTokenId, onChange: setAdminTokenId }),
154
+ /* @__PURE__ */ jsxRuntime.jsx(TokenSelect, { tokens, value: adminTokenId, onChange: setAdminTokenId, allowPickOnConnect: signIn === "none" }),
140
155
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.Field.Hint, {})
141
156
  ]
142
157
  }
143
158
  ),
144
- /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Field.Root, { name: "type", children: [
159
+ signIn === "none" && /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Field.Root, { name: "type", children: [
145
160
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.Field.Label, { children: "Client type" }),
146
161
  /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Radio.Group, { value: confidential, onValueChange: setConfidential, "aria-label": "Client type", children: [
147
162
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.Radio.Item, { value: "confidential", children: "Confidential (client ID + secret)" }),
@@ -151,7 +166,7 @@ const CreateClientModal = ({ onCreated, tokens }) => {
151
166
  ] }) }),
152
167
  /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Modal.Footer, { children: [
153
168
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.Modal.Close, { children: /* @__PURE__ */ jsxRuntime.jsx(designSystem.Button, { variant: "tertiary", children: created ? "Done" : "Cancel" }) }),
154
- !created && /* @__PURE__ */ jsxRuntime.jsx(designSystem.Button, { onClick: submit, loading: submitting, disabled: !name.trim() || !redirectUris.trim(), children: "Create" })
169
+ !created && /* @__PURE__ */ jsxRuntime.jsx(designSystem.Button, { onClick: submit, loading: submitting, disabled: !name.trim() || (signIn === "line" ? adminTokenId === PICK_ON_CONNECT : !redirectUris.trim()), children: "Create" })
155
170
  ] })
156
171
  ] })
157
172
  ]
@@ -245,7 +260,16 @@ const HomePage = () => {
245
260
  /* @__PURE__ */ jsxRuntime.jsx(CopyValue, { label: "Authorization server metadata", value: overview.endpoints.authorizationServerMetadata }),
246
261
  /* @__PURE__ */ jsxRuntime.jsx(CopyValue, { label: "Authorization endpoint", value: overview.endpoints.authorization }),
247
262
  /* @__PURE__ */ jsxRuntime.jsx(CopyValue, { label: "Token endpoint", value: overview.endpoints.token }),
248
- /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Typography, { variant: "pi", textColor: "neutral600", children: [
263
+ overview.lineSignIn.configured ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
264
+ /* @__PURE__ */ jsxRuntime.jsx(CopyValue, { label: "Token exchange grant type", value: overview.tokenExchange.grantType }),
265
+ /* @__PURE__ */ jsxRuntime.jsx(CopyValue, { label: "Subject token type (LINE ID token)", value: overview.tokenExchange.subjectTokenType }),
266
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Typography, { tag: "p", variant: "pi", textColor: "neutral600", children: [
267
+ "LINE sign-in is on for channel ",
268
+ overview.lineSignIn.channelId,
269
+ ". Customer apps post liff.getIDToken() to the token endpoint with a LINE client's ID."
270
+ ] })
271
+ ] }) : clients.some((client) => client.endUserProvider === "line") && /* @__PURE__ */ jsxRuntime.jsx(designSystem.Alert, { variant: "warning", title: "LINE sign-in is off", closeLabel: "Close", children: "A LINE client exists, but identityProviders.line.channelId isn't set in this plugin's config." }),
272
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Typography, { tag: "p", variant: "pi", textColor: "neutral600", children: [
249
273
  "Dynamic client registration is ",
250
274
  overview.dynamicClientRegistration ? "on" : "off",
251
275
  "."
@@ -256,9 +280,10 @@ const HomePage = () => {
256
280
  {
257
281
  title: "Connected sessions",
258
282
  subtitle: "Each session uses an admin token owned by the person who approved it. Deactivating that person, or deleting or regenerating the token in Settings → Admin Tokens, also ends the session.",
259
- children: grants.length === 0 ? /* @__PURE__ */ jsxRuntime.jsx(designSystem.EmptyStateLayout, { content: "No MCP clients are connected yet." }) : /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Table, { colCount: 6, rowCount: grants.length + 1, children: [
283
+ children: grants.length === 0 ? /* @__PURE__ */ jsxRuntime.jsx(designSystem.EmptyStateLayout, { content: "No MCP clients are connected yet." }) : /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Table, { colCount: 7, rowCount: grants.length + 1, children: [
260
284
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.Thead, { children: /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Tr, { children: [
261
285
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.Th, { children: /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "sigma", children: "Client" }) }),
286
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Th, { children: /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "sigma", children: "Customer" }) }),
262
287
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.Th, { children: /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "sigma", children: "Approved by" }) }),
263
288
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.Th, { children: /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "sigma", children: "Access" }) }),
264
289
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.Th, { children: /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "sigma", children: "Last used" }) }),
@@ -267,6 +292,7 @@ const HomePage = () => {
267
292
  ] }) }),
268
293
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.Tbody, { children: grants.map((grant) => /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Tr, { children: [
269
294
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.Td, { children: /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { fontWeight: "semiBold", children: grant.clientName }) }),
295
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Td, { children: /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { children: grant.subject ?? "—" }) }),
270
296
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.Td, { children: /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { gap: 2, children: [
271
297
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { children: grant.userEmail ?? "—" }),
272
298
  !grant.userActive && /* @__PURE__ */ jsxRuntime.jsx(designSystem.Badge, { variant: "danger", children: "Inactive" })
@@ -284,7 +310,7 @@ const HomePage = () => {
284
310
  children: /* @__PURE__ */ jsxRuntime.jsx(icons.Trash, {})
285
311
  }
286
312
  ),
287
- /* @__PURE__ */ jsxRuntime.jsx(
313
+ !grant.subject && /* @__PURE__ */ jsxRuntime.jsx(
288
314
  designSystem.IconButton,
289
315
  {
290
316
  label: `Revoke every session approved by ${grant.userEmail ?? "this user"}`,
@@ -325,15 +351,17 @@ const HomePage = () => {
325
351
  ] }) }),
326
352
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.Td, { children: /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { gap: 1, children: [
327
353
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.Badge, { variant: client.registrationType === "dynamic" ? "secondary" : "primary", children: client.registrationType === "dynamic" ? "Self-registered" : "Manual" }),
328
- /* @__PURE__ */ jsxRuntime.jsx(designSystem.Badge, { children: client.tokenEndpointAuthMethod === "none" ? "Public" : "Confidential" })
354
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Badge, { children: client.tokenEndpointAuthMethod === "none" ? "Public" : "Confidential" }),
355
+ client.endUserProvider === "line" && /* @__PURE__ */ jsxRuntime.jsx(designSystem.Badge, { variant: "success", children: "LINE sign-in" })
329
356
  ] }) }),
330
- /* @__PURE__ */ jsxRuntime.jsx(designSystem.Td, { children: /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "pi", style: { wordBreak: "break-all" }, children: client.redirectUris.join(", ") }) }),
357
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Td, { children: /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "pi", style: { wordBreak: "break-all" }, children: client.redirectUris.length ? client.redirectUris.join(", ") : "—" }) }),
331
358
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.Td, { children: /* @__PURE__ */ jsxRuntime.jsx(designSystem.Box, { minWidth: "220px", children: /* @__PURE__ */ jsxRuntime.jsx(
332
359
  TokenSelect,
333
360
  {
334
361
  tokens,
335
362
  current: client.adminToken,
336
363
  value: client.adminToken ? String(client.adminToken.id) : PICK_ON_CONNECT,
364
+ allowPickOnConnect: client.endUserProvider !== "line",
337
365
  onChange: (next) => run(
338
366
  () => put(`/${index.PLUGIN_ID}/clients/${client.id}`, {
339
367
  adminTokenId: next === PICK_ON_CONNECT ? null : Number(next)
@@ -37,7 +37,7 @@ const index = {
37
37
  },
38
38
  permissions: [{ action: `plugin::${PLUGIN_ID}.manage`, subject: null }],
39
39
  Component: async () => {
40
- const { App } = await import("./App-Ddu3EpO8.mjs");
40
+ const { App } = await import("./App-BLcSXikb.mjs");
41
41
  return App;
42
42
  }
43
43
  });
@@ -38,7 +38,7 @@ const index = {
38
38
  },
39
39
  permissions: [{ action: `plugin::${PLUGIN_ID}.manage`, subject: null }],
40
40
  Component: async () => {
41
- const { App } = await Promise.resolve().then(() => require("./App-WKeHpRl7.js"));
41
+ const { App } = await Promise.resolve().then(() => require("./App-CxPrn10p.js"));
42
42
  return App;
43
43
  }
44
44
  });
@@ -1,3 +1,3 @@
1
1
  "use strict";
2
- const index = require("../_chunks/index-BPOlIxN_.js");
2
+ const index = require("../_chunks/index-dzfso1OG.js");
3
3
  module.exports = index.index;
@@ -1,4 +1,4 @@
1
- import { i } from "../_chunks/index-BrZfQfFg.mjs";
1
+ import { i } from "../_chunks/index-B36PlzpY.mjs";
2
2
  export {
3
3
  i as default
4
4
  };