@xignet/payload 0.1.0-beta.1 → 0.1.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,6 +4,49 @@ Xignet resource authorization for Payload v3. The adapter uses the official MCP
4
4
 
5
5
  The current test baseline is Payload and `@payloadcms/plugin-mcp` **3.93.0**. No broad v3 or v4 compatibility is declared. The upstream plugin currently resolves `mcp-handler` 1.1.0 with SDK 1.30.0 despite that handler declaring SDK 1.26.0 as its exact peer; the workspace explicitly acknowledges this one tested combination using pnpm.peerDependencyRules.allowedVersions. Strict-peer pnpm consumers must apply the same narrow rule. This does not permit any other SDK version. Do not infer compatibility with other versions from these fixture results.
6
6
 
7
+ ## Standard plugin: passwordless managed users
8
+
9
+ ```ts
10
+ import { xignetPlugin } from '@xignet/payload';
11
+ import { createDirectoryClient } from '@xignet/client/directory';
12
+
13
+ const xignet = xignetPlugin({
14
+ resource: 'https://cms.example.com/api/mcp',
15
+ admissionScopes: ['cms:access'],
16
+ userCollection: 'users',
17
+ directory: createDirectoryClient({
18
+ clientId: process.env.XIGNET_DIRECTORY_CLIENT_ID!,
19
+ clientSecret: process.env.XIGNET_DIRECTORY_CLIENT_SECRET!,
20
+ }),
21
+ users: {
22
+ canManageBindings: (req) => req.user?.collection === 'users' && req.user.role === 'admin',
23
+ allowUser: (user) => ['admin', 'editor'].includes(user.role),
24
+ revokeOnFields: ['role'],
25
+ },
26
+ mcp: { enabled: true, collections: { bulletins: { find: { scopes: ['cms:read'] } } } },
27
+ login: { /* dedicated web client and session options shown below */ },
28
+ });
29
+
30
+ // Standard Payload plugin: buildConfig({ plugins: [xignet] }).
31
+ // The same xignet object works with createXignetRouteHandlers.
32
+ ```
33
+
34
+ The shared provider currently uses `https://signet.marxbio.tech`, the issuer advertised by the production Xignet AS. Projects do not repeat issuer/JWKS settings. A paired `provider: { issuer, jwksUri }` override supports tests or self-hosted Xignet; user data and request headers cannot select the trusted provider.
35
+
36
+ The plugin owns read-only `xignetIssuer`/`xignetSubject`, `xignetEnabled`, a compound unique identity index, a virtual confirmation field, the Directory lookup endpoint and revocation hooks. An administrator enters a company email, queries Xignet, confirms the identity and chooses a local role. The server resolves the email again on save and checks a five-minute signed preview bound to the acting administrator. A changed result requires a new preview; browser-supplied identity values cannot replace the Directory result. An existing binding cannot transfer to another issuer/subject. Email changes for the same subject, enablement, roles and deletion revoke existing sessions.
37
+
38
+ `POST /api/<userCollection>/xignet/resolve` requires an authenticated binding manager and an Origin matching the configured resource origin. This uses `@xignet/client/directory` server-side credentials, not the admin's CMS access token. SDK clients must never be exposed to browser code. Omitting `directory` disables provisioning and lookup, while allowing existing users to authenticate. Generate the Payload import map for both `@xignet/payload/components` and `@xignet/payload/client`; the latter requires the matching `@payloadcms/ui` peer.
39
+
40
+ At browser login the verified ID token must include `email_verified: true` and an email matching the bound local account. MCP continues to resolve only the verified `(issuer, sub)` and local eligibility; it does not expect an ID token or email claim. Directory resolves at provisioning time, not on every session/MCP request. This is not continuous synchronization or instant upstream revocation.
41
+
42
+ Managed users use Payload's supported `disableLocalStrategy: { enableFields: true }`, keeping auth/session fields while disabling local password operations. New records contain no password hash/salt. A custom `xignet-session` strategy accepts only the signed Payload SSO cookie/JWT with a valid native SID, private metadata, current local binding and absolute deadline. The session strategy does not accept Xignet access tokens; optional REST and MCP adapters validate those separately. API keys require the explicit transitional `legacyAPIKeys` option. Unmanaged custom strategies and automatic Admin login are rejected for this mode. Existing password hashes are retained in additive migrations but cannot authenticate.
43
+
44
+ Create the first admin through a trusted deployment/seed script using `payload.create` with the approved company email and explicit local role, without a password. Payload's normal public `first-register` password flow is disabled in this mode. An unauthenticated trusted server-side Local API create still resolves Directory but does not need an interactive confirmation. Local API calls carrying a user retain permission and preview checks. After Xignet login, that admin creates subsequent users in the Admin Panel. Generate and review database migrations in each project before enabling the plugin; it never pushes schema at runtime.
45
+
46
+ ## Low-level adapter: custom resolver and mixed authentication
47
+
48
+ The existing `/v3` API below remains available when a project owns its binding storage or intentionally keeps native password/API-key authentication. Those choices are independent of the standard plugin's Xignet-only policy.
49
+
7
50
  ```ts
8
51
  import { createXignetPayload } from '@xignet/payload/v3';
9
52
 
@@ -37,7 +80,7 @@ export const GET = handlers.metadata;
37
80
 
38
81
  The example's `resolveExistingLocalUser` belongs to the project, not this package. A successful resolver must return a real Payload user with an ID and the configured auth collection discriminator. Resolver failures propagate; they do not become an unbound-user response.
39
82
 
40
- For a resource at `/api/mcp`, mount metadata at `/.well-known/oauth-protected-resource/api/mcp`. The resource origin must match configured `serverURL`, and its path must match the Payload API route plus `/mcp`. Trust settings are fixed configuration, never derived from request Host headers or token claims. Only the MCP routes accept Xignet Bearer; no global authentication strategy or cookie/API-key fallback is installed.
83
+ For a resource at `/api/mcp`, mount metadata at `/.well-known/oauth-protected-resource/api/mcp`. The resource origin must match configured `serverURL`, and its path must match the Payload API route plus `/mcp`. Trust settings are fixed configuration, never derived from request Host headers or token claims. MCP validates its own Bearer tokens without cookie/API-key fallback. Optional REST support installs a separate authentication strategy and requires the HTTP boundary described below.
41
84
 
42
85
  MCP operations require explicit scopes. Unsupported/unlisted operations, entities and experimental tools are closed. Deletion is not supported in this prerelease. The API-key collection added by the official plugin is hidden and denied to API callers; its schema is retained even with MCP disabled, so projects must include it in migrations. `mcp.enabled: false` removes the MCP routes and returns 404 metadata.
43
86
 
@@ -90,7 +133,7 @@ const handlers = createXignetRouteHandlers(xignet, {
90
133
  // Export GET = handlers.metadata at the resource's well-known path.
91
134
  ```
92
135
 
93
- Provide the app's actual Payload instance lazily. SSO-enabled route factories require this provider. SSO requires the native local auth strategy and `useSessions`; MCP-only consumers may use other auth models. The adapter invokes beforeLogin/afterLogin hooks and composes existing refresh hooks. These HTTP login routes return redirects or generic error responses with no token body.
136
+ Provide the app's actual Payload instance lazily. SSO-enabled route factories require this provider. The low-level adapter's default mixed mode uses native local auth with `useSessions`; the standard plugin uses the dedicated Xignet-only strategy described above. MCP-only low-level consumers may use other auth models. The adapter invokes beforeLogin/afterLogin hooks and composes existing refresh hooks. These HTTP login routes return redirects or generic error responses with no token body.
94
137
 
95
138
  Next standalone can expose its internal hostname and port in `Request.url`. The Next callback handler uses the trusted Payload `serverURL` as its public origin while retaining the incoming path and query for OIDC validation. Configure `serverURL` to the canonical CMS origin; Host and forwarded-host headers never select that origin. Direct `integration.callback` callers must supply the canonical public request URL themselves.
96
139
 
@@ -106,7 +149,7 @@ Login failure rolls back SID/metadata when the adapter supports transactions, or
106
149
 
107
150
  The adapter logs structured MCP operation/transport outcomes with a generated request ID and verified issuer/subject/client/local user, without content bodies or credentials. SSO refusals, service failures and revocation have structured events; unexpected exception messages are not copied into HTTP error responses or logs.
108
151
 
109
- Publish core first, then payload under `payload-vX.Y.Z` using `payload-publish.yml`. Configure npm trusted publishing for both new package names before tagging. Only the exact peer baseline above is supported in this prerelease; Payload's Node requirement is `^22.23.4 || >=24.22.1`; a server runtime is required. Adapter CI verifies Node 22.23.4, 24 and 26.13.1. Core independently supports Node 22.22.0+. The package's Next helpers use standard Request/Response and have no Next.js runtime import.
152
+ Ensure the required core version is available, publish and verify the new client version through `client-publish.yml`, then publish payload under `payload-vX.Y.Z` using `payload-publish.yml`. Configure npm trusted publishing for both new package names before tagging. Only the exact peer baseline above is supported in this prerelease; Payload's Node requirement is `^22.23.4 || >=24.22.1`; a server runtime is required. Adapter CI verifies Node 22.23.4, 24 and 26.13.1. Core independently supports Node 22.22.0+. The package's Next helpers use standard Request/Response and have no Next.js runtime import.
110
153
 
111
154
  RPC audit events use the server-generated request ID, principal, allowlisted method/tool target and outcome (success, denied, failed, partial_failure or unobserved). They never include tool arguments, response content, access tokens or exception messages. Inspection retains at most 64 KiB per message. Response bytes are forwarded as the consumer reads them, with terminal audit on EOF, error or cancellation; an open stream does not block delivery. An oversized or unreadable request is recorded as unobserved. Official tool response hooks also report execution failures and partial bulk results, even when the transport returns HTTP 200.
112
155
 
@@ -117,3 +160,30 @@ For staged rollout, configure the final collection/global policies once and set
117
160
  ## Reusing the adapter
118
161
 
119
162
  Each CMS supplies its own canonical resource/serverURL, web client, secrets, auth collection, local resolver and collection/global policies. Keep project binding and role rules in that resolver and call `revokeUserSessions` from local lifecycle hooks; mount the shared Next handlers rather than copying the OIDC flow. Tests cover a second CMS profile with a `members` auth collection, separate origin/client/admission scope, native login, refresh and revocation. This fixture verifies configurable reuse; it is not a claim that a second production project has been deployed.
163
+
164
+ Directory audit records use generated `x-request-id` correlation IDs, local actor/user IDs and fixed operation/outcome/reason fields. Identity values, preview proofs and credentials are not logged. Downstream deployments must preserve the correlation header when forwarding to Directory.
165
+
166
+ ### REST access tokens and API-key migration
167
+
168
+ REST is opt-in and shares the CMS `resource` audience with MCP, normally `https://cms.example.com/api/mcp`. Directory has its own audience. Configure `rest.enabled`, `admissionScopes`, explicit `collections` / `globals` operation scopes, and optional exact custom `endpoints`. REST and MCP each enforce their own route, scope and local access policies. Only document GET, collection POST, document PATCH and global GET/POST are supported by the REST adapter. Delete, bulk update, auth, versions and GraphQL routes are denied for REST Bearer tokens.
169
+
170
+ ```ts
171
+ const integration = xignetPlugin({
172
+ // Existing provider, users, MCP and login configuration...
173
+ legacyAPIKeys: true, // temporary migration bridge; default is false
174
+ rest: {
175
+ enabled: true,
176
+ admissionScopes: ['cms:access'],
177
+ collections: {
178
+ pages: { read: ['cms:read'], create: ['cms:write', 'cms:publish'], update: ['cms:write', 'cms:publish'] },
179
+ },
180
+ services: [{ subject: 'service:cms-sync', clientId: 'service:cms-sync' }],
181
+ },
182
+ })
183
+ ```
184
+
185
+ **Wrap every Payload REST HTTP method** with `integration.rest(request, payload, () => originalHandler(request, context))`. This boundary is required: Payload catches strategy exceptions and otherwise may continue as anonymous. The wrapper rejects an invalid Bearer token before dispatch, independently of any cookie. It carries the verified principal through the request's async context, so authentication cannot silently fall back after a second lookup. MCP keeps its existing endpoint validation. Browser cookie requests and transitional API-key requests retain normal Payload behavior. Other custom HTTP/GraphQL entry points must not reuse REST credentials.
186
+
187
+ Human subjects resolve through the managed local binding and eligibility policy on every request. Services must have the reserved `service:` subject equal to `client_id` and match the local `rest.services` allowlist. Their principal has collection `xignet-services` and no human role, password or session; this is an in-memory principal, not a new database collection. Existing collection/global access control is preserved. Where appropriate, explicitly allow `isXignetService(req)` in the application's **content** access policy; the adapter still caps access to configured operation scopes. Never add this exception to user management or admin access. Field access rules also remain in force.
188
+
189
+ Keep `legacyAPIKeys: true` only while existing consumers migrate. It does not re-enable passwords. Deploy REST validation first, register environment-specific machine clients for the shared CMS audience and least required scopes, migrate consumers, verify their reads/writes/uploads, then set `legacyAPIKeys: false`. No automatic token-to-API-key fallback exists. Machine authorization revocation at the AS is bounded by the access token's lifetime; removing the local service allowlist entry takes effect with the new configuration. No remote introspection is performed.
@@ -0,0 +1,9 @@
1
+ /** Only public collection metadata crosses the client-component boundary. */
2
+ export declare function XignetDirectoryField({ path, collection, }: {
3
+ path: string;
4
+ collection: string;
5
+ }): import("react").DetailedReactHTMLElement<{
6
+ style: {
7
+ marginBottom: string;
8
+ };
9
+ }, HTMLElement>;
package/dist/client.js ADDED
@@ -0,0 +1,57 @@
1
+ 'use client';
2
+ import { createElement, useState } from 'react';
3
+ import { useConfig, useField } from '@payloadcms/ui';
4
+ import { directoryMessages } from './directory-messages.js';
5
+ /** Only public collection metadata crosses the client-component boundary. */
6
+ export function XignetDirectoryField({ path, collection, }) {
7
+ const { config } = useConfig();
8
+ const email = useField({ path: 'email' });
9
+ const confirmation = useField({ path });
10
+ const [preview, setPreview] = useState();
11
+ const [message, setMessage] = useState('');
12
+ const [busy, setBusy] = useState(false);
13
+ const current = typeof email.value === 'string' ? email.value.trim().toLowerCase() : '';
14
+ async function lookup() {
15
+ setBusy(true);
16
+ setMessage('');
17
+ setPreview(undefined);
18
+ confirmation.setValue('');
19
+ try {
20
+ const response = await fetch(`${config.routes.api}/${encodeURIComponent(collection)}/xignet/resolve`, {
21
+ method: 'POST',
22
+ credentials: 'same-origin',
23
+ headers: { 'content-type': 'application/json' },
24
+ body: JSON.stringify({ email: current }),
25
+ });
26
+ if (!response.ok) {
27
+ const error = (await response.json().catch(() => null));
28
+ const code = error?.error;
29
+ setMessage(typeof code === 'string' && Object.hasOwn(directoryMessages, code)
30
+ ? directoryMessages[code]
31
+ : response.status === 403
32
+ ? '沒有身分查詢權限。'
33
+ : response.status === 400
34
+ ? directoryMessages.invalid_email
35
+ : directoryMessages.unavailable);
36
+ return;
37
+ }
38
+ const result = (await response.json());
39
+ setPreview(result.identity);
40
+ confirmation.setValue(result.confirmation);
41
+ }
42
+ catch {
43
+ setMessage('身分查詢暫時無法使用,請稍後再試。');
44
+ }
45
+ finally {
46
+ setBusy(false);
47
+ }
48
+ }
49
+ return createElement('div', { style: { marginBottom: '1.5rem' } }, createElement('button', {
50
+ type: 'button',
51
+ disabled: busy || !current,
52
+ onClick: lookup,
53
+ className: 'btn btn--style-secondary',
54
+ }, busy ? '查詢中…' : '查詢 Xignet 身分'), preview && preview.email === current
55
+ ? createElement('p', { role: 'status' }, `公司信箱:${preview.email}|Xignet sub:${preview.sub}。請確認身分後儲存。`)
56
+ : createElement('p', null, '新增用戶或變更 email 時,請先查詢並確認公司身分。'), message ? createElement('p', { role: 'alert' }, message) : null);
57
+ }
@@ -0,0 +1,10 @@
1
+ /** Server component; configuration contains only display data, never secrets. */
2
+ export declare function XignetLogin({ enabled, loginPath, label, }: {
3
+ enabled: boolean;
4
+ loginPath: string;
5
+ label: string;
6
+ }): import("react").DetailedReactHTMLElement<import("react").HTMLAttributes<HTMLElement>, HTMLElement> | import("react").DetailedReactHTMLElement<{
7
+ style: {
8
+ marginBottom: string;
9
+ };
10
+ }, HTMLElement>;
@@ -0,0 +1,7 @@
1
+ import { createElement } from 'react';
2
+ /** Server component; configuration contains only display data, never secrets. */
3
+ export function XignetLogin({ enabled, loginPath, label, }) {
4
+ return enabled
5
+ ? createElement('div', { style: { marginBottom: '1.5rem' } }, createElement('a', { className: 'btn btn--style-primary', href: loginPath }, label))
6
+ : createElement('p', null, 'Xignet sign-in is not enabled.');
7
+ }
@@ -0,0 +1,12 @@
1
+ import { type Endpoint, type PayloadRequest } from 'payload';
2
+ import { type DirectoryClient, type DirectoryIdentity } from '@xignet/client/directory';
3
+ export declare function directoryBindings({ directory, issuer, collection, origin, canManage, }: {
4
+ directory?: DirectoryClient;
5
+ issuer: string;
6
+ collection: string;
7
+ origin: string;
8
+ canManage(req: PayloadRequest): boolean | Promise<boolean>;
9
+ }): {
10
+ endpoint: Endpoint;
11
+ verify: (req: PayloadRequest, email: string, confirmation: unknown) => Promise<DirectoryIdentity>;
12
+ };
@@ -0,0 +1,135 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { jwtVerify, SignJWT } from 'jose';
3
+ import { APIError } from 'payload';
4
+ import { canonicalEmail, DirectoryError, } from '@xignet/client/directory';
5
+ import { directoryMessages as messages } from './directory-messages.js';
6
+ export function directoryBindings({ directory, issuer, collection, origin, canManage, }) {
7
+ const key = (req) => createHash('sha256')
8
+ .update(`xignet-directory-confirmation:${req.payload.secret}`)
9
+ .digest();
10
+ async function resolve(req, email, operation) {
11
+ const requestId = randomUUID();
12
+ req.context.xignetDirectoryRequestId = requestId;
13
+ const audit = {
14
+ event: 'xignet_directory_lookup',
15
+ requestId,
16
+ operation,
17
+ actorId: req.user?.id ?? 'trusted-local',
18
+ };
19
+ try {
20
+ if (!directory)
21
+ throw new DirectoryError('unavailable');
22
+ const identity = await directory.resolve(canonicalEmail(email), {
23
+ requestId,
24
+ });
25
+ if (identity.issuer !== issuer ||
26
+ identity.email !== canonicalEmail(email) ||
27
+ !identity.sub ||
28
+ identity.sub.startsWith('service:'))
29
+ throw new DirectoryError('invalid_response');
30
+ req.payload.logger.info({ ...audit, outcome: 'success' });
31
+ return identity;
32
+ }
33
+ catch (error) {
34
+ const code = error instanceof DirectoryError && Object.hasOwn(messages, error.code)
35
+ ? error.code
36
+ : 'unavailable';
37
+ req.payload.logger.warn({ ...audit, outcome: 'denied', reason: code });
38
+ throw new APIError(messages[code], code === 'invalid_email'
39
+ ? 400
40
+ : code === 'not_found'
41
+ ? 404
42
+ : code === 'rate_limited'
43
+ ? 429
44
+ : 503, { code }, true);
45
+ }
46
+ }
47
+ const endpoint = {
48
+ path: '/xignet/resolve',
49
+ method: 'post',
50
+ handler: async (req) => {
51
+ if (!req.user || !(await canManage(req)))
52
+ throw new APIError('Forbidden', 403);
53
+ if (req.headers.get('origin') !== origin)
54
+ throw new APIError('Forbidden origin', 403);
55
+ const text = await req.text?.();
56
+ if (!text || text.length > 1024)
57
+ throw new APIError('Invalid lookup request', 400);
58
+ let email;
59
+ try {
60
+ email = JSON.parse(text).email;
61
+ }
62
+ catch {
63
+ throw new APIError('Invalid lookup request', 400);
64
+ }
65
+ if (typeof email !== 'string')
66
+ throw new APIError('Invalid email', 400);
67
+ let identity;
68
+ try {
69
+ identity = await resolve(req, email, 'preview');
70
+ }
71
+ catch (error) {
72
+ if (!(error instanceof APIError))
73
+ throw error;
74
+ return Response.json({ error: error.data?.code ?? 'unavailable' }, {
75
+ status: error.status,
76
+ headers: {
77
+ 'cache-control': 'no-store',
78
+ 'x-request-id': String(req.context.xignetDirectoryRequestId),
79
+ },
80
+ });
81
+ }
82
+ const confirmation = await new SignJWT({
83
+ identity,
84
+ actor: String(req.user.id),
85
+ })
86
+ .setProtectedHeader({ alg: 'HS256' })
87
+ .setIssuer('xignet-directory-confirmation')
88
+ .setAudience(`${origin}/${collection}`)
89
+ .setIssuedAt()
90
+ .setExpirationTime('5m')
91
+ .sign(key(req));
92
+ return Response.json({ identity, confirmation }, {
93
+ headers: {
94
+ 'cache-control': 'no-store',
95
+ 'x-request-id': String(req.context.xignetDirectoryRequestId),
96
+ },
97
+ });
98
+ },
99
+ };
100
+ async function verify(req, email, confirmation) {
101
+ const identity = await resolve(req, email, 'save');
102
+ // Only trusted server-side Local API calls can skip the interactive preview.
103
+ if (req.payloadAPI === 'local' && !req.user)
104
+ return identity;
105
+ if (!req.user || !(await canManage(req)))
106
+ throw new APIError('Forbidden', 403);
107
+ try {
108
+ if (typeof confirmation !== 'string')
109
+ throw new Error();
110
+ const { payload } = await jwtVerify(confirmation, key(req), {
111
+ algorithms: ['HS256'],
112
+ issuer: 'xignet-directory-confirmation',
113
+ audience: `${origin}/${collection}`,
114
+ requiredClaims: ['exp', 'iat'],
115
+ });
116
+ const prior = payload.identity;
117
+ if (payload.actor !== String(req.user.id) ||
118
+ prior?.issuer !== identity.issuer ||
119
+ prior.sub !== identity.sub ||
120
+ prior.email !== identity.email)
121
+ throw new Error();
122
+ }
123
+ catch {
124
+ req.payload.logger.warn({
125
+ event: 'xignet_directory_confirmation_rejected',
126
+ requestId: req.context.xignetDirectoryRequestId,
127
+ actorId: req.user.id,
128
+ reason: 'confirmation_invalid',
129
+ });
130
+ throw new APIError('請重新查詢並確認 Xignet 身分後儲存。', 409);
131
+ }
132
+ return identity;
133
+ }
134
+ return { endpoint, verify };
135
+ }
@@ -0,0 +1,9 @@
1
+ export declare const directoryMessages: {
2
+ invalid_email: string;
3
+ not_found: string;
4
+ unauthorized: string;
5
+ forbidden: string;
6
+ rate_limited: string;
7
+ unavailable: string;
8
+ invalid_response: string;
9
+ };
@@ -0,0 +1,9 @@
1
+ export const directoryMessages = {
2
+ invalid_email: '請輸入完整公司 email。',
3
+ not_found: '查無可使用此 CMS 的確定身分,請聯絡 Xignet 管理員。',
4
+ unauthorized: '身分查詢服務認證失敗,請聯絡管理員。',
5
+ forbidden: '此 CMS 尚未獲得身分查詢權限。',
6
+ rate_limited: '查詢過於頻繁,請稍後再試。',
7
+ unavailable: '身分查詢暫時無法使用,請稍後再試。',
8
+ invalid_response: '身分查詢回應無法驗證,請聯絡管理員。',
9
+ };
package/dist/login.js CHANGED
@@ -175,7 +175,10 @@ export function createLogin(options, sessions) {
175
175
  },
176
176
  });
177
177
  user._sid = sid;
178
- user._strategy = 'local-jwt';
178
+ user._strategy =
179
+ options.sessionAuthentication === 'xignet-only'
180
+ ? 'xignet-session'
181
+ : 'local-jwt';
179
182
  req.user = user;
180
183
  for (const hook of collectionConfig.hooks.afterLogin) {
181
184
  user =
@@ -0,0 +1,22 @@
1
+ import { type DirectoryClient } from '@xignet/client/directory';
2
+ import { type CollectionConfig, type PayloadRequest, type TypedUser } from 'payload';
3
+ import type { ResolveUser, XignetPayloadIntegration } from './types.js';
4
+ export interface ManagedUsersOptions<TUser extends TypedUser = TypedUser> {
5
+ /** Project policy for the administrators allowed to edit identity bindings. */
6
+ readonly canManageBindings: (req: PayloadRequest) => boolean | Promise<boolean>;
7
+ /** Local account eligibility; the plugin does not assign business roles. */
8
+ readonly allowUser: (user: TUser) => boolean | Promise<boolean>;
9
+ /** Local fields whose changes must revoke SSO sessions, for example role. */
10
+ readonly revokeOnFields?: readonly string[];
11
+ }
12
+ export declare function createManagedUsers<TUser extends TypedUser>({ issuer, resource, directory, userCollection, policy, revoke, }: {
13
+ issuer: string;
14
+ resource: string;
15
+ directory?: DirectoryClient;
16
+ userCollection: string;
17
+ policy: ManagedUsersOptions<TUser>;
18
+ revoke: XignetPayloadIntegration['revokeUserSessions'];
19
+ }): {
20
+ resolveUser: ResolveUser<TUser>;
21
+ enhanceUser: (config: CollectionConfig) => CollectionConfig;
22
+ };
@@ -0,0 +1,206 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { canonicalEmail } from '@xignet/client/directory';
3
+ import { directoryBindings } from './directory-binding.js';
4
+ import { APIError, } from 'payload';
5
+ export function createManagedUsers({ issuer, resource, directory, userCollection, policy, revoke, }) {
6
+ const bindings = directoryBindings({
7
+ directory,
8
+ issuer,
9
+ collection: userCollection,
10
+ origin: new URL(resource).origin,
11
+ canManage: policy.canManageBindings,
12
+ });
13
+ const changedFields = [
14
+ ...new Set([
15
+ 'email',
16
+ 'xignetIssuer',
17
+ 'xignetSubject',
18
+ 'xignetEnabled',
19
+ ...(policy.revokeOnFields ?? []),
20
+ ]),
21
+ ];
22
+ const resolveUser = async ({ identity, payload, channel, login, }) => {
23
+ if (identity.issuer !== issuer)
24
+ return { status: 'denied', reason: 'not_allowed' };
25
+ const found = await payload.find({
26
+ collection: userCollection,
27
+ overrideAccess: true,
28
+ limit: 2,
29
+ depth: 0,
30
+ where: {
31
+ and: [
32
+ { xignetIssuer: { equals: identity.issuer } },
33
+ { xignetSubject: { equals: identity.subject } },
34
+ ],
35
+ },
36
+ });
37
+ if (!found.docs.length)
38
+ return { status: 'denied', reason: 'unbound' };
39
+ if (found.docs.length !== 1)
40
+ return { status: 'denied', reason: 'not_allowed' };
41
+ const user = found.docs[0];
42
+ if (channel === 'login' &&
43
+ (login?.idTokenClaims.email_verified !== true ||
44
+ typeof login.idTokenClaims.email !== 'string' ||
45
+ canonicalEmail(login.idTokenClaims.email) !== user.email))
46
+ return { status: 'denied', reason: 'not_allowed' };
47
+ if (!user.xignetEnabled)
48
+ return { status: 'denied', reason: 'disabled' };
49
+ if (!(await policy.allowUser(user)))
50
+ return { status: 'denied', reason: 'not_allowed' };
51
+ return { status: 'allowed', user: { ...user, collection: userCollection } };
52
+ };
53
+ const enhanceUser = (config) => {
54
+ const reserved = [
55
+ 'xignetIssuer',
56
+ 'xignetSubject',
57
+ 'xignetEnabled',
58
+ 'xignetDirectoryConfirmation',
59
+ ];
60
+ if (config.fields.some((field) => 'name' in field && reserved.includes(field.name)))
61
+ throw new Error('Managed Xignet identity fields are installed by the plugin');
62
+ const access = ({ req }) => policy.canManageBindings(req);
63
+ return {
64
+ ...config,
65
+ endpoints: [...(config.endpoints || []), bindings.endpoint],
66
+ indexes: [
67
+ ...(config.indexes ?? []),
68
+ {
69
+ fields: ['xignetIssuer', 'xignetSubject'],
70
+ unique: true,
71
+ },
72
+ ],
73
+ fields: [
74
+ ...config.fields,
75
+ {
76
+ name: 'xignetDirectoryConfirmation',
77
+ type: 'text',
78
+ virtual: true,
79
+ access: { read: access, create: access, update: access },
80
+ admin: {
81
+ components: {
82
+ Field: {
83
+ path: '@xignet/payload/client#XignetDirectoryField',
84
+ clientProps: { collection: userCollection },
85
+ },
86
+ },
87
+ },
88
+ },
89
+ {
90
+ name: 'xignetIssuer',
91
+ type: 'text',
92
+ label: 'Xignet issuer',
93
+ admin: {
94
+ readOnly: true,
95
+ description: 'Managed by the Xignet provider configuration.',
96
+ },
97
+ access: { read: access, create: access, update: access },
98
+ },
99
+ {
100
+ name: 'xignetSubject',
101
+ type: 'text',
102
+ label: 'Xignet sub',
103
+ admin: {
104
+ readOnly: true,
105
+ description: '由公司 email 查詢取得的 Xignet 身分。',
106
+ },
107
+ access: { read: access, create: access, update: access },
108
+ },
109
+ {
110
+ name: 'xignetEnabled',
111
+ type: 'checkbox',
112
+ defaultValue: true,
113
+ label: 'Enable Xignet access',
114
+ access: { read: access, create: access, update: access },
115
+ },
116
+ ],
117
+ hooks: {
118
+ ...config.hooks,
119
+ beforeValidate: [
120
+ ...(config.hooks?.beforeValidate ?? []),
121
+ async ({ data, originalDoc, operation, req }) => {
122
+ if (!data)
123
+ return data;
124
+ const trusted = req.payloadAPI === 'local' && !req.user;
125
+ const protectedFields = ['email', ...reserved];
126
+ const changes = protectedFields.some((field) => data[field] !== undefined &&
127
+ data[field] !== originalDoc?.[field]);
128
+ if ((operation === 'create' || changes) &&
129
+ !trusted &&
130
+ !(await policy.canManageBindings(req)))
131
+ throw new APIError('Xignet binding management is forbidden', 403);
132
+ let email;
133
+ try {
134
+ email =
135
+ data.email === undefined
136
+ ? originalDoc?.email
137
+ : canonicalEmail(data.email);
138
+ }
139
+ catch {
140
+ throw new APIError('請輸入完整公司 email。', 400, null, true);
141
+ }
142
+ const needsLookup = operation === 'create' ||
143
+ email !== originalDoc?.email ||
144
+ (data.xignetEnabled === true && !originalDoc?.xignetEnabled) ||
145
+ Boolean(data.xignetDirectoryConfirmation);
146
+ if (needsLookup) {
147
+ const identity = await bindings.verify(req, email, data.xignetDirectoryConfirmation);
148
+ if (operation !== 'create' &&
149
+ originalDoc?.xignetSubject &&
150
+ (originalDoc.xignetSubject !== identity.sub ||
151
+ originalDoc.xignetIssuer !== identity.issuer))
152
+ throw new APIError('現有帳號不能改綁另一個 Xignet 身分。', 409);
153
+ for (const [field, value] of Object.entries({
154
+ xignetIssuer: identity.issuer,
155
+ xignetSubject: identity.sub,
156
+ })) {
157
+ if (data[field] != null &&
158
+ data[field] !== '' &&
159
+ data[field] !== value)
160
+ throw new APIError('Xignet identity is managed by Directory', 400);
161
+ data[field] = value;
162
+ }
163
+ data.email = identity.email;
164
+ }
165
+ else {
166
+ for (const field of ['xignetIssuer', 'xignetSubject'])
167
+ if (data[field] !== undefined &&
168
+ data[field] !== originalDoc?.[field])
169
+ throw new APIError('Xignet identity is managed by Directory', 400);
170
+ }
171
+ delete data.xignetDirectoryConfirmation;
172
+ return data;
173
+ },
174
+ ],
175
+ afterChange: [
176
+ ...(config.hooks?.afterChange ?? []),
177
+ async ({ doc, previousDoc, req, operation }) => {
178
+ if (operation === 'update' &&
179
+ previousDoc &&
180
+ changedFields.some((field) => doc[field] !== previousDoc[field]))
181
+ await revoke({ payload: req.payload, userId: doc.id, req });
182
+ if (operation === 'create' ||
183
+ !previousDoc ||
184
+ changedFields.some((field) => doc[field] !== previousDoc[field]))
185
+ req.payload.logger.info({
186
+ event: 'xignet_user_changed',
187
+ outcome: 'applied',
188
+ requestId: req.context.xignetDirectoryRequestId ?? randomUUID(),
189
+ actorId: req.user?.id ?? 'trusted-local',
190
+ userId: doc.id,
191
+ operation,
192
+ });
193
+ return doc;
194
+ },
195
+ ],
196
+ beforeDelete: [
197
+ ...(config.hooks?.beforeDelete ?? []),
198
+ async ({ id, req }) => {
199
+ await revoke({ payload: req.payload, userId: id, req });
200
+ },
201
+ ],
202
+ },
203
+ };
204
+ };
205
+ return { resolveUser, enhanceUser };
206
+ }