@payloadcms/figma 0.0.1-alpha.66 → 0.0.1-alpha.68

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.
@@ -1,5 +1,6 @@
1
1
  import Conf from 'conf';
2
2
  import crypto from 'crypto';
3
+ import envPaths from 'env-paths';
3
4
  import fsSync from 'node:fs';
4
5
  import os from 'os';
5
6
  import path from 'path';
@@ -10,20 +11,56 @@ import path from 'path';
10
11
  * for future use. This ensures the encryption key is unique per machine and not
11
12
  * reproducible even if machine identifiers are known.
12
13
  *
14
+ * Uses an atomic create-or-read pattern (O_EXCL via `flag: 'wx'`) so concurrent
15
+ * callers — e.g. parallel vitest workers on a fresh CI container — converge on
16
+ * the same salt instead of clobbering each other's writes. Once the file
17
+ * exists, no caller ever overwrites it.
18
+ *
13
19
  * @returns 32-byte hex string (64 characters)
14
20
  */ function getOrCreateSalt() {
15
- // Configuration store for cryptographic salt
16
- const cryptoConf = new Conf({
17
- configName: 'payloadcms-figma-crypto',
18
- projectName: 'payloadcms-figma'
21
+ // Path mirrors what `Conf({ projectName: 'payloadcms-figma', configName:
22
+ // 'payloadcms-figma-crypto' })` would produce, so files written by older
23
+ // versions remain readable.
24
+ const configDir = envPaths('payloadcms-figma', {
25
+ suffix: 'nodejs'
26
+ }).config;
27
+ const configPath = path.join(configDir, 'payloadcms-figma-crypto.json');
28
+ const readSalt = ()=>{
29
+ try {
30
+ const parsed = JSON.parse(fsSync.readFileSync(configPath, 'utf8'));
31
+ return typeof parsed.salt === 'string' && parsed.salt.length > 0 ? parsed.salt : undefined;
32
+ } catch (err) {
33
+ if (err.code === 'ENOENT') {
34
+ return undefined;
35
+ }
36
+ throw err;
37
+ }
38
+ };
39
+ const existing = readSalt();
40
+ if (existing) {
41
+ return existing;
42
+ }
43
+ fsSync.mkdirSync(configDir, {
44
+ recursive: true
19
45
  });
20
- let salt = cryptoConf.get('salt');
21
- if (!salt) {
22
- // Generate cryptographically secure random salt (32 bytes = 256 bits)
23
- salt = crypto.randomBytes(32).toString('hex');
24
- cryptoConf.set('salt', salt);
46
+ const candidate = crypto.randomBytes(32).toString('hex');
47
+ try {
48
+ fsSync.writeFileSync(configPath, JSON.stringify({
49
+ salt: candidate
50
+ }, null, '\t'), {
51
+ flag: 'wx'
52
+ });
53
+ return candidate;
54
+ } catch (err) {
55
+ if (err.code !== 'EEXIST') {
56
+ throw err;
57
+ }
58
+ const winner = readSalt();
59
+ if (!winner) {
60
+ throw new Error('Failed to read salt after losing create-race');
61
+ }
62
+ return winner;
25
63
  }
26
- return salt;
27
64
  }
28
65
  /**
29
66
  * Gather machine-specific entropy sources
@@ -0,0 +1,63 @@
1
+ export interface CmsOAuthCodeMessage {
2
+ code: string;
3
+ state: string;
4
+ }
5
+ /**
6
+ * Validate the shape of a message received from the parent window. Accepts
7
+ * `{ type: 'cms-oauth-code', code: string, state: string }`. Returns null
8
+ * for anything else. The handler additionally verifies `state` matches the
9
+ * value the iframe sent on its outbound `mint-cms-oauth-code` request.
10
+ */
11
+ export declare const parseCmsOAuthCodeMessage: (data: unknown) => CmsOAuthCodeMessage | null;
12
+ /**
13
+ * Derive a tight `targetOrigin` for `window.parent.postMessage` from the
14
+ * iframe's `document.referrer`. If the referrer is a Figma origin we return
15
+ * it verbatim; otherwise (stripped by Referrer-Policy, malformed, or hostile)
16
+ * we fall back to '*'. The outbound payload contains the server-signed
17
+ * `state` from /meta — no secrets to leak — so '*' is acceptable; the
18
+ * inbound origin check is the load-bearing security control.
19
+ */
20
+ export declare const pickParentTargetOrigin: (referrer: null | string | undefined) => string;
21
+ export interface IframeAutoLoginDeps {
22
+ doFetch: typeof fetch;
23
+ /**
24
+ * URL of the plugin's `/meta` endpoint. The iframe fetches this on setup
25
+ * to get the OAuth params (`client_id`, `redirect_uri`, `scope`,
26
+ * `code_challenge`, `state`, etc.) it forwards to the parent so the
27
+ * parent can mint a code via Sinatra.
28
+ */
29
+ metaUrl: string;
30
+ navigate: (url: string) => void;
31
+ /**
32
+ * Called on known synchronous failure paths so the host UI can render a
33
+ * manual fallback (e.g. surface the regular login button) instead of
34
+ * leaving the user on a blank screen. Not called on cleanup-driven
35
+ * aborts. Silent failures (parent never responds) are not detected here;
36
+ * the host is expected to apply its own timeout.
37
+ */
38
+ onFailed?: () => void;
39
+ parentReferrer: null | string | undefined;
40
+ postMessageToParent: (message: unknown, targetOrigin: string) => void;
41
+ win: Pick<Window, 'addEventListener' | 'removeEventListener'>;
42
+ }
43
+ /**
44
+ * Wire up the iframe auto-login flow:
45
+ * 1. Fetch the plugin's `/meta` endpoint to get the OAuth params for this
46
+ * flow — same params the regular `/admin/login` button would have used.
47
+ * 2. postMessage to the parent window:
48
+ * { type: 'mint-cms-oauth-code', client_id, redirect_uri, scope,
49
+ * code_challenge, code_challenge_method, state }
50
+ * The parent uses these to mint a code via Sinatra, then echoes `state`
51
+ * back so we can correlate response to request.
52
+ * 3. Listen for the parent's response.
53
+ * 4. On a valid Figma-origin response whose `state` matches what we sent,
54
+ * navigate to `${redirect_uri}?code=${code}&state=${state}` — the same
55
+ * URL shape the IdP uses when it 302s back into the manual flow.
56
+ * 5. `/sso/login` GET handler validates state.sig + PKCE verifier, exchanges
57
+ * the code, sets session cookies, and 302s to `/admin`.
58
+ *
59
+ * Returns a cleanup function that removes the listener and aborts the meta
60
+ * fetch — designed to be returned directly from a React useEffect.
61
+ */
62
+ export declare const setupIframeAutoLogin: (deps: IframeAutoLoginDeps) => (() => void);
63
+ //# sourceMappingURL=iframeAutoLogin.d.ts.map
@@ -0,0 +1,153 @@
1
+ import { isFigmaOrigin } from '../../utilities/figmaHostnames.js';
2
+ /**
3
+ * Validate the shape of a message received from the parent window. Accepts
4
+ * `{ type: 'cms-oauth-code', code: string, state: string }`. Returns null
5
+ * for anything else. The handler additionally verifies `state` matches the
6
+ * value the iframe sent on its outbound `mint-cms-oauth-code` request.
7
+ */ export const parseCmsOAuthCodeMessage = (data)=>{
8
+ if (!data || typeof data !== 'object') {
9
+ return null;
10
+ }
11
+ const obj = data;
12
+ if (obj.type !== 'cms-oauth-code') {
13
+ return null;
14
+ }
15
+ if (typeof obj.code !== 'string' || obj.code.length === 0) {
16
+ return null;
17
+ }
18
+ if (typeof obj.state !== 'string' || obj.state.length === 0) {
19
+ return null;
20
+ }
21
+ return {
22
+ code: obj.code,
23
+ state: obj.state
24
+ };
25
+ };
26
+ /**
27
+ * Derive a tight `targetOrigin` for `window.parent.postMessage` from the
28
+ * iframe's `document.referrer`. If the referrer is a Figma origin we return
29
+ * it verbatim; otherwise (stripped by Referrer-Policy, malformed, or hostile)
30
+ * we fall back to '*'. The outbound payload contains the server-signed
31
+ * `state` from /meta — no secrets to leak — so '*' is acceptable; the
32
+ * inbound origin check is the load-bearing security control.
33
+ */ export const pickParentTargetOrigin = (referrer)=>{
34
+ if (!referrer) {
35
+ return '*';
36
+ }
37
+ try {
38
+ const candidate = new URL(referrer).origin;
39
+ return isFigmaOrigin(candidate) ? candidate : '*';
40
+ } catch {
41
+ return '*';
42
+ }
43
+ };
44
+ /**
45
+ * Wire up the iframe auto-login flow:
46
+ * 1. Fetch the plugin's `/meta` endpoint to get the OAuth params for this
47
+ * flow — same params the regular `/admin/login` button would have used.
48
+ * 2. postMessage to the parent window:
49
+ * { type: 'mint-cms-oauth-code', client_id, redirect_uri, scope,
50
+ * code_challenge, code_challenge_method, state }
51
+ * The parent uses these to mint a code via Sinatra, then echoes `state`
52
+ * back so we can correlate response to request.
53
+ * 3. Listen for the parent's response.
54
+ * 4. On a valid Figma-origin response whose `state` matches what we sent,
55
+ * navigate to `${redirect_uri}?code=${code}&state=${state}` — the same
56
+ * URL shape the IdP uses when it 302s back into the manual flow.
57
+ * 5. `/sso/login` GET handler validates state.sig + PKCE verifier, exchanges
58
+ * the code, sets session cookies, and 302s to `/admin`.
59
+ *
60
+ * Returns a cleanup function that removes the listener and aborts the meta
61
+ * fetch — designed to be returned directly from a React useEffect.
62
+ */ export const setupIframeAutoLogin = (deps)=>{
63
+ const abortController = new AbortController();
64
+ // `flow` is captured atomically from the /meta response. Either both
65
+ // `redirectUri` and `state` are set (after the async fetch completes
66
+ // successfully), or it's null. Bundling them prevents handleMessage
67
+ // from using a half-populated state — e.g. an empty `redirectUri`
68
+ // producing a relative-URL navigation.
69
+ let flow = null;
70
+ const handleMessage = (event)=>{
71
+ // Parse first so we only log rejections for messages that were *intended*
72
+ // to be `cms-oauth-code`. Unrelated postMessages (browser extensions,
73
+ // devtools, etc.) return silently with no log noise.
74
+ const parsed = parseCmsOAuthCodeMessage(event.data);
75
+ if (!parsed) {
76
+ return;
77
+ }
78
+ if (!isFigmaOrigin(event.origin)) {
79
+ // eslint-disable-next-line no-console -- surface why auto-login didn't fire
80
+ console.error(`[payload iframe auto-login] rejected cms-oauth-code: origin "${event.origin}" is not in the Figma allowlist`);
81
+ return;
82
+ }
83
+ if (flow === null) {
84
+ // eslint-disable-next-line no-console -- surface why auto-login didn't fire
85
+ console.error('[payload iframe auto-login] rejected cms-oauth-code: message arrived before /meta resolved, so no expected state to compare against');
86
+ return;
87
+ }
88
+ if (parsed.state !== flow.state) {
89
+ // eslint-disable-next-line no-console -- surface why auto-login didn't fire
90
+ console.error('[payload iframe auto-login] rejected cms-oauth-code: state mismatch — the parent did not echo back the state the iframe sent');
91
+ return;
92
+ }
93
+ // /sso/login expects the same URL shape the IdP would have produced:
94
+ // ${redirect_uri}?code=…&state=…
95
+ deps.navigate(`${flow.redirectUri}?code=${encodeURIComponent(parsed.code)}&state=${encodeURIComponent(parsed.state)}`);
96
+ };
97
+ deps.win.addEventListener('message', handleMessage);
98
+ void (async ()=>{
99
+ try {
100
+ const metaRes = await deps.doFetch(deps.metaUrl, {
101
+ signal: abortController.signal
102
+ });
103
+ if (!metaRes.ok) {
104
+ // eslint-disable-next-line no-console -- surface auto-login failures to dev tools
105
+ console.warn(`[payload iframe auto-login] meta endpoint responded ${metaRes.status}`);
106
+ deps.onFailed?.();
107
+ return;
108
+ }
109
+ const meta = await metaRes.json();
110
+ const clientId = meta.params?.client_id;
111
+ const codeChallenge = meta.params?.code_challenge;
112
+ const codeChallengeMethod = meta.params?.code_challenge_method;
113
+ const redirect = meta.params?.redirect_uri;
114
+ const scope = meta.params?.scope;
115
+ const state = meta.params?.state;
116
+ if (typeof clientId !== 'string' || typeof codeChallenge !== 'string' || typeof codeChallengeMethod !== 'string' || typeof redirect !== 'string' || typeof scope !== 'string' || typeof state !== 'string') {
117
+ // eslint-disable-next-line no-console -- surface auto-login failures to dev tools
118
+ console.warn('[payload iframe auto-login] meta response missing required params');
119
+ deps.onFailed?.();
120
+ return;
121
+ }
122
+ if (abortController.signal.aborted) {
123
+ return;
124
+ }
125
+ flow = {
126
+ redirectUri: redirect,
127
+ state
128
+ };
129
+ deps.postMessageToParent({
130
+ type: 'mint-cms-oauth-code',
131
+ client_id: clientId,
132
+ code_challenge: codeChallenge,
133
+ code_challenge_method: codeChallengeMethod,
134
+ redirect_uri: redirect,
135
+ scope,
136
+ state
137
+ }, pickParentTargetOrigin(deps.parentReferrer));
138
+ } catch (err) {
139
+ if (abortController.signal.aborted) {
140
+ return;
141
+ }
142
+ // eslint-disable-next-line no-console -- surface auto-login failures to dev tools
143
+ console.warn('[payload iframe auto-login] failed to fetch meta', err);
144
+ deps.onFailed?.();
145
+ }
146
+ })();
147
+ return ()=>{
148
+ deps.win.removeEventListener('message', handleMessage);
149
+ abortController.abort();
150
+ };
151
+ };
152
+
153
+ //# sourceMappingURL=iframeAutoLogin.js.map
@@ -3,8 +3,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useConfig } from '@payloadcms/ui';
4
4
  import { useSearchParams } from 'next/navigation.js';
5
5
  import React, { useEffect, useState } from 'react';
6
+ import { setupIframeAutoLogin } from './iframeAutoLogin.js';
6
7
  import './index.scss';
7
8
  const baseClass = 'oauth-login';
9
+ // Max time we wait for the parent (figma.com) to respond with a code after
10
+ // posting `mint-cms-oauth-code`. If we hit this without a reply, we assume
11
+ // the parent isn't going to respond and fall back to the manual login button.
12
+ const AUTO_LOGIN_PARENT_REPLY_TIMEOUT_MS = 5000;
8
13
  // Figma logo SVG colored version
9
14
  // const FigmaIcon: React.FC = () => (
10
15
  // <svg fill="none" height="27" viewBox="0 0 400 600" width="18">
@@ -47,8 +52,59 @@ const FigmaIcon = ()=>/*#__PURE__*/ _jsx("svg", {
47
52
  export const DefaultLoginButton = ({ disabled, endpointSlug })=>{
48
53
  const { config: { admin: { user: userSlug }, routes: { api }, serverURL } } = useConfig();
49
54
  const [authorizeURL, setAuthorizeURL] = useState('');
55
+ const [isInIframe, setIsInIframe] = useState(null);
56
+ const [autoLoginFailed, setAutoLoginFailed] = useState(false);
50
57
  const searchParams = useSearchParams();
51
58
  const payloadRedirect = searchParams.get('redirect');
59
+ useEffect(()=>{
60
+ try {
61
+ setIsInIframe(window.self !== window.top);
62
+ } catch {
63
+ // Cross-origin access throws — that itself means we're in an iframe.
64
+ setIsInIframe(true);
65
+ }
66
+ }, []);
67
+ // Iframe auto-login: ask the parent (figma.com) for a fresh OAuth code, then
68
+ // navigate to `/sso/login?code=…&state=…` — the same handler the manual
69
+ // login button flow reaches. /sso/login exchanges the code, sets session
70
+ // cookies, and redirects to /admin. Runs on initial /admin/login load and
71
+ // after logout. See `./iframeAutoLogin.ts` for the flow + tests.
72
+ //
73
+ // If the flow fails — synchronously via `onFailed`, or silently via the
74
+ // timeout — we flip `autoLoginFailed` so the manual login button renders
75
+ // as a fallback instead of leaving the user on a blank screen.
76
+ useEffect(()=>{
77
+ if (isInIframe !== true || disabled) {
78
+ return;
79
+ }
80
+ const fallbackTimer = window.setTimeout(()=>{
81
+ // eslint-disable-next-line no-console -- surface silent parent failures
82
+ console.warn(`[payload iframe auto-login] parent did not reply with cms-oauth-code within ${AUTO_LOGIN_PARENT_REPLY_TIMEOUT_MS}ms — falling back to manual login`);
83
+ setAutoLoginFailed(true);
84
+ }, AUTO_LOGIN_PARENT_REPLY_TIMEOUT_MS);
85
+ const cleanup = setupIframeAutoLogin({
86
+ doFetch: window.fetch.bind(window),
87
+ metaUrl: `${serverURL}${api}/${userSlug}/${endpointSlug}/meta?serverURL=${window.location.origin}`,
88
+ navigate: (url)=>{
89
+ window.location.href = url;
90
+ },
91
+ onFailed: ()=>setAutoLoginFailed(true),
92
+ parentReferrer: document.referrer,
93
+ postMessageToParent: (msg, target)=>window.parent.postMessage(msg, target),
94
+ win: window
95
+ });
96
+ return ()=>{
97
+ window.clearTimeout(fallbackTimer);
98
+ cleanup();
99
+ };
100
+ }, [
101
+ isInIframe,
102
+ disabled,
103
+ serverURL,
104
+ api,
105
+ userSlug,
106
+ endpointSlug
107
+ ]);
52
108
  useEffect(()=>{
53
109
  if (payloadRedirect && !disabled) {
54
110
  // set cookie to redirect to the original page
@@ -58,23 +114,48 @@ export const DefaultLoginButton = ({ disabled, endpointSlug })=>{
58
114
  payloadRedirect,
59
115
  disabled
60
116
  ]);
117
+ // Fetch the manual login URL whenever we'd render the button — either we
118
+ // aren't in an iframe, or we're in an iframe but auto-login has given up.
119
+ const shouldRenderButton = !disabled && (isInIframe === false || autoLoginFailed);
61
120
  useEffect(()=>{
121
+ if (!shouldRenderButton) {
122
+ return;
123
+ }
62
124
  const getAuthorizeURL = async ()=>{
63
125
  const serverURLFromWindow = window.location.origin;
64
126
  const data = await fetch(`${serverURL}${api}/${userSlug}/${endpointSlug}/meta?serverURL=${serverURLFromWindow}`).then((res)=>res.json());
65
- if (!disabled) {
66
- setAuthorizeURL(data.authorizeURL);
67
- }
127
+ setAuthorizeURL(data.authorizeURL);
68
128
  };
69
129
  void getAuthorizeURL();
70
130
  }, [
71
131
  api,
72
132
  serverURL,
73
133
  userSlug,
74
- disabled,
134
+ shouldRenderButton,
75
135
  endpointSlug
76
136
  ]);
77
- if (disabled) {
137
+ if (!shouldRenderButton) {
138
+ // In the iframe auto-login window, show a "Signing in…" hint so the
139
+ // user sees something is happening instead of a blank slot until the
140
+ // flow either navigates them away or times out into the manual button.
141
+ if (isInIframe === true && !disabled) {
142
+ return /*#__PURE__*/ _jsx("div", {
143
+ className: baseClass,
144
+ children: /*#__PURE__*/ _jsxs("div", {
145
+ className: `${baseClass}__signing-in`,
146
+ role: "status",
147
+ children: [
148
+ /*#__PURE__*/ _jsx("span", {
149
+ children: "Signing in"
150
+ }),
151
+ /*#__PURE__*/ _jsx("div", {
152
+ "aria-hidden": "true",
153
+ className: `${baseClass}__spinner`
154
+ })
155
+ ]
156
+ })
157
+ });
158
+ }
78
159
  return null;
79
160
  }
80
161
  return /*#__PURE__*/ _jsx("div", {
@@ -54,4 +54,29 @@
54
54
  font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
55
55
  Oxygen, Ubuntu, Cantarell, sans-serif;
56
56
  }
57
+
58
+ &__signing-in {
59
+ display: flex;
60
+ flex-direction: column;
61
+ align-items: center;
62
+ gap: 0.75rem;
63
+ font-size: 1rem;
64
+ font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
65
+ Oxygen, Ubuntu, Cantarell, sans-serif;
66
+ }
67
+
68
+ &__spinner {
69
+ width: 24px;
70
+ height: 24px;
71
+ border: 2px solid var(--theme-elevation-200, #e5e5e5);
72
+ border-top-color: currentColor;
73
+ border-radius: 50%;
74
+ animation: oauth-login-spin 0.8s linear infinite;
75
+ }
76
+ }
77
+
78
+ @keyframes oauth-login-spin {
79
+ to {
80
+ transform: rotate(360deg);
81
+ }
57
82
  }
@@ -7,18 +7,30 @@ import './index.scss';
7
7
  const baseClass = 'oauth-logout';
8
8
  export const LogoutButton = ({ disabled, endpointSlug })=>{
9
9
  const { config, config: { admin: { user: userSlug }, routes: { api }, serverURL } } = useConfig();
10
+ // In an iframe (Figma Make Preview) the LoginButton auto-logs the user
11
+ // back in as soon as they hit /admin/login, which would silently undo the
12
+ // logout. Redirect to the site root instead so the user actually stays
13
+ // logged out. Outside an iframe, keep the original behavior of redirecting
14
+ // back to /admin.
10
15
  const [redirect, setRedirect] = React.useState('');
11
16
  React.useEffect(()=>{
12
17
  const protocol = window.location.protocol;
13
18
  const domain = window.location.hostname;
14
19
  const port = window.location.port;
15
- let redirectToSet = `${protocol}//${domain}`;
20
+ let origin = `${protocol}//${domain}`;
16
21
  if (port) {
17
- redirectToSet += `:${port}`;
22
+ origin += `:${port}`;
18
23
  }
19
- setRedirect(formatAdminURL({
24
+ let isInIframe;
25
+ try {
26
+ isInIframe = window.self !== window.top;
27
+ } catch {
28
+ // Cross-origin access throws — that itself means we're in an iframe.
29
+ isInIframe = true;
30
+ }
31
+ setRedirect(isInIframe ? origin : formatAdminURL({
20
32
  adminRoute: config.routes.admin,
21
- serverURL: redirectToSet
33
+ serverURL: origin
22
34
  }));
23
35
  }, [
24
36
  config
@@ -4,6 +4,7 @@ import { createDebugLogger } from '../utilities/createDebugLogger.js';
4
4
  import { establishSession } from '../utilities/establishSession.js';
5
5
  import { exchangeCodeForAccessToken } from '../utilities/exchangeCodeForAccessToken.js';
6
6
  import { extractOrigin } from '../utilities/extractOrigin.js';
7
+ import { buildCsrfCookieClearHeader, OAUTH_STATE_CSRF_COOKIE_NAME } from '../utilities/getAuthorizeURL.js';
7
8
  import { isAbsoluteURL } from '../utilities/isAbsoluteURL.js';
8
9
  export const getLoginEndpoint = ({ collection, collectionOptions, endpointSlug, pluginOptions, strategy })=>({
9
10
  handler: async (req)=>{
@@ -98,6 +99,16 @@ export const getLoginEndpoint = ({ collection, collectionOptions, endpointSlug,
98
99
  req.payload.logger.error('Signature mismatch. Failing login and redirecting.');
99
100
  return Response.redirect(failedRedirect);
100
101
  }
102
+ // Verify CSRF cookie matches the nonce baked into state at /meta time.
103
+ // This is the browser-binding required by RFC 6749 §10.12 — without
104
+ // it, an attacker who mints (code, state) via /meta + consent could
105
+ // replay them into a victim's browser by URL.
106
+ const cookies = parseCookies(req.headers);
107
+ const cookieCsrf = cookies.get(OAUTH_STATE_CSRF_COOKIE_NAME);
108
+ if (!cookieCsrf || cookieCsrf !== state.csrf) {
109
+ req.payload.logger.error('CSRF cookie mismatch. Failing login and redirecting.');
110
+ return Response.redirect(failedRedirect);
111
+ }
101
112
  if (!strategy.meta?.token_endpoint) {
102
113
  debugLogger.error({
103
114
  msg: `No metadata was obtained from the identity provider. Failing login and redirecting.`,
@@ -164,11 +175,13 @@ export const getLoginEndpoint = ({ collection, collectionOptions, endpointSlug,
164
175
  });
165
176
  throw new APIError(errMsg);
166
177
  }
167
- const cookies = parseCookies(req.headers);
168
- const payloadRedirect = 'payloadRedirect' in cookies ? cookies.payloadRedirect : '';
178
+ const payloadRedirect = cookies.get('payloadRedirect') ?? '';
169
179
  if (!req.responseHeaders) {
170
180
  req.responseHeaders = new Headers();
171
181
  }
182
+ // Clear the CSRF cookie now that the flow has completed — the nonce
183
+ // is single-use; leaving it around lets a replay try again.
184
+ req.responseHeaders.append('Set-Cookie', buildCsrfCookieClearHeader());
172
185
  if (payloadRedirect) {
173
186
  if (isAbsoluteURL(payloadRedirect)) {
174
187
  redirectToUse = payloadRedirect;
@@ -1,10 +1,18 @@
1
- import { getAuthorizeURL } from '../utilities/getAuthorizeURL.js';
1
+ import { parseCookies } from 'payload';
2
+ import { buildCsrfCookieHeader, getAuthorizeURL, OAUTH_STATE_CSRF_COOKIE_NAME } from '../utilities/getAuthorizeURL.js';
2
3
  export const getMetaEndpoint = ({ collection, collectionOptions, endpointSlug, pluginOptions, strategy })=>({
3
4
  handler: async (req)=>{
4
- const authorizeURL = await getAuthorizeURL({
5
+ // Reuse the CSRF nonce from the existing cookie if one is set. Makes
6
+ // `/meta` idempotent: a second call from the same browser (Strict Mode
7
+ // re-mount, retry, multi-tab) returns the SAME state.csrf, so the
8
+ // cookie never has to change after the first call — no last-write-wins
9
+ // race between the cookie and the iframe's already-captured state.
10
+ const existingCsrfNonce = parseCookies(req.headers).get(OAUTH_STATE_CSRF_COOKIE_NAME);
11
+ const { authorizeURL, csrfNonce, params } = await getAuthorizeURL({
5
12
  collection,
6
13
  collectionOptions,
7
14
  endpointSlug,
15
+ existingCsrfNonce,
8
16
  failedRedirect: req.query.failedRedirect,
9
17
  payload: req.payload,
10
18
  pluginOptions,
@@ -12,8 +20,15 @@ export const getMetaEndpoint = ({ collection, collectionOptions, endpointSlug, p
12
20
  serverURLOverride: req.query.serverURL,
13
21
  strategy
14
22
  });
23
+ if (!req.responseHeaders) {
24
+ req.responseHeaders = new Headers();
25
+ }
26
+ // Always re-emit Set-Cookie — when reusing, this just refreshes Max-Age
27
+ // so long-lived tabs don't expire the cookie mid-consent.
28
+ req.responseHeaders.append('Set-Cookie', buildCsrfCookieHeader(csrfNonce));
15
29
  return Response.json({
16
- authorizeURL
30
+ authorizeURL,
31
+ params
17
32
  }, {
18
33
  status: 200
19
34
  });
@@ -1,10 +1,15 @@
1
- import { getAuthorizeURL } from '../utilities/getAuthorizeURL.js';
1
+ import { parseCookies } from 'payload';
2
+ import { buildCsrfCookieHeader, getAuthorizeURL, OAUTH_STATE_CSRF_COOKIE_NAME } from '../utilities/getAuthorizeURL.js';
2
3
  export const getRedirectToLoginEndpoint = ({ collection, collectionOptions, endpointSlug, pluginOptions, strategy })=>({
3
4
  handler: async (req)=>{
4
- const authorizeURL = await getAuthorizeURL({
5
+ // Reuse the CSRF nonce from the existing cookie if one is set. See
6
+ // getMetaEndpoint.ts for the idempotency rationale.
7
+ const existingCsrfNonce = parseCookies(req.headers).get(OAUTH_STATE_CSRF_COOKIE_NAME);
8
+ const { authorizeURL, csrfNonce } = await getAuthorizeURL({
5
9
  collection,
6
10
  collectionOptions,
7
11
  endpointSlug,
12
+ existingCsrfNonce,
8
13
  failedRedirect: req.query.failedRedirect,
9
14
  payload: req.payload,
10
15
  pluginOptions,
@@ -12,6 +17,10 @@ export const getRedirectToLoginEndpoint = ({ collection, collectionOptions, endp
12
17
  serverURLOverride: req.query.serverURL,
13
18
  strategy
14
19
  });
20
+ if (!req.responseHeaders) {
21
+ req.responseHeaders = new Headers();
22
+ }
23
+ req.responseHeaders.append('Set-Cookie', buildCsrfCookieHeader(csrfNonce));
15
24
  return Response.redirect(authorizeURL);
16
25
  },
17
26
  method: 'get',
@@ -4,7 +4,6 @@ import { getLoginEndpoint } from './endpoints/getLoginEndpoint.js';
4
4
  import { getLogoutEndpoint } from './endpoints/getLogoutEndpoint.js';
5
5
  import { getMetaEndpoint } from './endpoints/getMetaEndpoint.js';
6
6
  import { getRedirectToLoginEndpoint } from './endpoints/getRedirectToLoginEndpoint.js';
7
- import { getTokenLoginEndpoint } from './endpoints/getTokenLoginEndpoint.js';
8
7
  import { getAfterLogout } from './hooks/afterLogout.js';
9
8
  import { getMeHook } from './hooks/me.js';
10
9
  import { getRefreshHook } from './hooks/refresh.js';
@@ -207,13 +206,6 @@ export const oAuth2Plugin = (pluginOptions)=>(config)=>{
207
206
  endpointSlug,
208
207
  pluginOptions,
209
208
  strategy
210
- }),
211
- getTokenLoginEndpoint({
212
- collection: existingCollection,
213
- collectionOptions,
214
- endpointSlug,
215
- pluginOptions,
216
- strategy
217
209
  })
218
210
  ],
219
211
  fields,
@@ -169,6 +169,13 @@ export type CookieOptions = {
169
169
  secure?: boolean;
170
170
  };
171
171
  export type StateObj = {
172
+ /**
173
+ * Random per-flow nonce. The server sets a matching HttpOnly cookie
174
+ * (`payload-oauth-state_csrf`) at the time the state is minted; `/sso/login`
175
+ * compares the value in state to the cookie on callback. This is the
176
+ * browser-bound CSRF defense required by RFC 6749 §10.12.
177
+ */
178
+ csrf: string;
172
179
  /**
173
180
  * Redirect to use if the login fails
174
181
  */
@@ -0,0 +1,3 @@
1
+ export declare const FIGMA_HOSTNAMES: Set<string>;
2
+ export declare const isFigmaOrigin: (origin: string) => boolean;
3
+ //# sourceMappingURL=figmaHostnames.d.ts.map
@@ -0,0 +1,26 @@
1
+ // Copied from share/figma-url/src/hostnames.ts.
2
+ // This package is published to npm (@payloadcms/figma) and cannot import from
3
+ // the monorepo's internal share/ tree, so the list is duplicated here.
4
+ export const FIGMA_HOSTNAMES = new Set([
5
+ 'embed.figma.com',
6
+ 'embed.figma-gov.com',
7
+ 'embed.local.figma.engineering',
8
+ 'embed.staging.figma.com',
9
+ 'figma.com',
10
+ 'figma-gov.com',
11
+ 'local.figma.engineering',
12
+ 'localhost',
13
+ 'staging.figma.com',
14
+ 'www.figma.com',
15
+ 'www.figma-gov.com'
16
+ ]);
17
+ export const isFigmaOrigin = (origin)=>{
18
+ try {
19
+ const { hostname } = new URL(origin);
20
+ return FIGMA_HOSTNAMES.has(hostname) || hostname.endsWith('.figdev.systems');
21
+ } catch {
22
+ return false;
23
+ }
24
+ };
25
+
26
+ //# sourceMappingURL=figmaHostnames.js.map
@@ -5,6 +5,16 @@ interface Args {
5
5
  collection: CollectionConfig;
6
6
  collectionOptions: CollectionOptions;
7
7
  endpointSlug: string;
8
+ /**
9
+ * If the request already has an `__Host-payload-oauth-state_csrf` cookie,
10
+ * pass its value here — `getAuthorizeURL` will reuse it as `state.csrf`
11
+ * instead of minting a fresh nonce. This is what makes `/meta`
12
+ * idempotent across multiple calls in a single browser session (React
13
+ * Strict Mode re-mounts, retry-after-failure, multiple tabs) and
14
+ * prevents the cookie/state mismatch race where one call overwrites
15
+ * another call's cookie.
16
+ */
17
+ existingCsrfNonce?: null | string;
8
18
  failedRedirect?: unknown;
9
19
  payload: Payload;
10
20
  pluginOptions: PluginOptions;
@@ -12,6 +22,49 @@ interface Args {
12
22
  serverURLOverride?: unknown;
13
23
  strategy: Strategy;
14
24
  }
15
- export declare const getAuthorizeURL: ({ collection, collectionOptions, endpointSlug, failedRedirect, payload, pluginOptions, redirect, serverURLOverride, strategy, }: Args) => Promise<string>;
25
+ export interface AuthorizeURLParams {
26
+ [key: string]: string;
27
+ client_id: string;
28
+ code_challenge: string;
29
+ code_challenge_method: string;
30
+ nonce: string;
31
+ redirect_uri: string;
32
+ response_mode: string;
33
+ response_type: string;
34
+ scope: string;
35
+ state: string;
36
+ }
37
+ export interface AuthorizeURLResult {
38
+ authorizeURL: string;
39
+ /**
40
+ * The plaintext CSRF nonce that the caller MUST set as an HttpOnly
41
+ * cookie on the response (see `OAUTH_STATE_CSRF_COOKIE_NAME`). `/sso/login`
42
+ * compares this value to the cookie on callback — this is the
43
+ * browser-binding required by RFC 6749 §10.12.
44
+ */
45
+ csrfNonce: string;
46
+ params: AuthorizeURLParams;
47
+ }
48
+ export declare const getAuthorizeURL: ({ collection, collectionOptions, endpointSlug, existingCsrfNonce, failedRedirect, payload, pluginOptions, redirect, serverURLOverride, strategy, }: Args) => Promise<AuthorizeURLResult>;
49
+ /**
50
+ * Name of the HttpOnly cookie that carries the browser-bound CSRF nonce
51
+ * for the OAuth flow. The `__Host-` prefix instructs browsers to enforce:
52
+ * Secure, no Domain attribute (host-only), and Path=/ — which together
53
+ * prevent sibling subdomains from setting or overwriting it.
54
+ */
55
+ export declare const OAUTH_STATE_CSRF_COOKIE_NAME = "__Host-payload-oauth-state_csrf";
56
+ /**
57
+ * Build the `Set-Cookie` header value for the CSRF cookie. SameSite=None
58
+ * + Secure so the cookie survives the IdP cross-site redirect back into
59
+ * /sso/login, AND so the iframe auto-login path works (the iframe is a
60
+ * third-party context from the top-level Figma page). Path=/ is required
61
+ * by the `__Host-` prefix.
62
+ */
63
+ export declare const buildCsrfCookieHeader: (nonce: string) => string;
64
+ /**
65
+ * `Set-Cookie` header to clear the CSRF cookie after a successful (or
66
+ * failed) callback. Same attributes as the set version, plus Max-Age=0.
67
+ */
68
+ export declare const buildCsrfCookieClearHeader: () => string;
16
69
  export {};
17
70
  //# sourceMappingURL=getAuthorizeURL.d.ts.map
@@ -2,14 +2,21 @@ import crypto from 'crypto';
2
2
  import * as QueryString from 'qs-esm';
3
3
  import { v4 as uuid } from 'uuid';
4
4
  import { defaultScope } from '../defaults.js';
5
- export const getAuthorizeURL = async ({ collection, collectionOptions, endpointSlug, failedRedirect, payload, pluginOptions, redirect, serverURLOverride, strategy })=>{
5
+ export const getAuthorizeURL = async ({ collection, collectionOptions, endpointSlug, existingCsrfNonce, failedRedirect, payload, pluginOptions, redirect, serverURLOverride, strategy })=>{
6
6
  const { redirectServerURL } = pluginOptions;
7
7
  await strategy.ensureMeta();
8
8
  // Generate a random code verifier
9
9
  const codeVerifier = crypto.randomBytes(32).toString('base64url');
10
10
  // Create a code challenge by hashing the code verifier
11
11
  const code_challenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
12
+ // Nonce that goes in both `state.csrf` and the `__Host-payload-oauth-state_csrf`
13
+ // HttpOnly cookie. /sso/login compares the two on callback to defeat
14
+ // login-CSRF (RFC 6749 §10.12). Reused from the request's existing cookie
15
+ // when present so `/meta` is idempotent across multiple calls; otherwise
16
+ // freshly minted.
17
+ const csrfNonce = existingCsrfNonce || crypto.randomBytes(32).toString('base64url');
12
18
  const state = {
19
+ csrf: csrfNonce,
13
20
  sig: payload.encrypt(strategy.name),
14
21
  verifier: payload.encrypt(codeVerifier)
15
22
  };
@@ -39,7 +46,42 @@ export const getAuthorizeURL = async ({ collection, collectionOptions, endpointS
39
46
  const authorizeURL = `${strategy.meta?.authorization_endpoint}?${QueryString.stringify(params, {
40
47
  encode: false
41
48
  })}`;
42
- return authorizeURL;
49
+ return {
50
+ authorizeURL,
51
+ csrfNonce,
52
+ params
53
+ };
43
54
  };
55
+ /**
56
+ * Name of the HttpOnly cookie that carries the browser-bound CSRF nonce
57
+ * for the OAuth flow. The `__Host-` prefix instructs browsers to enforce:
58
+ * Secure, no Domain attribute (host-only), and Path=/ — which together
59
+ * prevent sibling subdomains from setting or overwriting it.
60
+ */ export const OAUTH_STATE_CSRF_COOKIE_NAME = '__Host-payload-oauth-state_csrf';
61
+ /**
62
+ * Build the `Set-Cookie` header value for the CSRF cookie. SameSite=None
63
+ * + Secure so the cookie survives the IdP cross-site redirect back into
64
+ * /sso/login, AND so the iframe auto-login path works (the iframe is a
65
+ * third-party context from the top-level Figma page). Path=/ is required
66
+ * by the `__Host-` prefix.
67
+ */ export const buildCsrfCookieHeader = (nonce)=>[
68
+ `${OAUTH_STATE_CSRF_COOKIE_NAME}=${nonce}`,
69
+ 'HttpOnly',
70
+ 'Secure',
71
+ 'SameSite=None',
72
+ 'Path=/',
73
+ 'Max-Age=600'
74
+ ].join('; ');
75
+ /**
76
+ * `Set-Cookie` header to clear the CSRF cookie after a successful (or
77
+ * failed) callback. Same attributes as the set version, plus Max-Age=0.
78
+ */ export const buildCsrfCookieClearHeader = ()=>[
79
+ `${OAUTH_STATE_CSRF_COOKIE_NAME}=`,
80
+ 'HttpOnly',
81
+ 'Secure',
82
+ 'SameSite=None',
83
+ 'Path=/',
84
+ 'Max-Age=0'
85
+ ].join('; ');
44
86
 
45
87
  //# sourceMappingURL=getAuthorizeURL.js.map
@@ -1,4 +1,13 @@
1
1
  import type { Config, SanitizedConfig } from 'payload';
2
+ /**
3
+ * Worker-trusted marker injected by Figma's sites-worker after it validates the
4
+ * X-Figma-Job-Queues-Auth-Bypass secret on a Payload jobs endpoint. The default
5
+ * `jobs.access.run` below honors this marker so Sinatra (async-job-workflow)
6
+ * can drive job runs without the bypass secret ever reaching tenant code.
7
+ *
8
+ * KEEP_IN_SYNC(cloudflare/wrangler/sites-worker/src/auth.ts): FIGMA_INTERNAL_FORWARDED_HAS_AUTH_BYPASS_HEADER
9
+ */
10
+ export declare const FIGMA_INTERNAL_FORWARDED_HAS_AUTH_BYPASS_HEADER = "figma-internal-forwarded-has-auth-bypass";
2
11
  /**
3
12
  * Configuration type for Payload projects on the Figma platform.
4
13
  *
@@ -20,6 +20,14 @@ import * as log from '../utils/log.js';
20
20
  import { logMissingCliAuth } from './auth-preflight.js';
21
21
  import { logMissingContentSystemId } from './bootstrap-preflight.js';
22
22
  import { getDevCookieNames } from './dev-cookie-names.js';
23
+ /**
24
+ * Worker-trusted marker injected by Figma's sites-worker after it validates the
25
+ * X-Figma-Job-Queues-Auth-Bypass secret on a Payload jobs endpoint. The default
26
+ * `jobs.access.run` below honors this marker so Sinatra (async-job-workflow)
27
+ * can drive job runs without the bypass secret ever reaching tenant code.
28
+ *
29
+ * KEEP_IN_SYNC(cloudflare/wrangler/sites-worker/src/auth.ts): FIGMA_INTERNAL_FORWARDED_HAS_AUTH_BYPASS_HEADER
30
+ */ export const FIGMA_INTERNAL_FORWARDED_HAS_AUTH_BYPASS_HEADER = 'figma-internal-forwarded-has-auth-bypass';
23
31
  /**
24
32
  * Fields added to users collection for Figma profile info
25
33
  */ const figmaUserFields = [
@@ -243,6 +251,7 @@ export async function buildFigmaConfig(config) {
243
251
  url
244
252
  });
245
253
  }
254
+ const userJobsAccessRun = config.jobs?.access?.run;
246
255
  // Build complete config with Figma platform defaults
247
256
  const configWithFigmaDefaults = {
248
257
  ...config,
@@ -267,6 +276,25 @@ export async function buildFigmaConfig(config) {
267
276
  health,
268
277
  schema
269
278
  ],
279
+ // Gate /api/payload-jobs/run and /api/payload-jobs/handle-schedules. Honor the
280
+ // sites-worker trust marker so Figma infra job runs never break, defer to the
281
+ // tenant's jobs.access.run if they set one, otherwise fall back to Payload's
282
+ // "logged-in user" default so the admin "Run jobs" UI keeps working.
283
+ jobs: {
284
+ ...config.jobs ?? {},
285
+ access: {
286
+ ...config.jobs?.access ?? {},
287
+ run: (args)=>{
288
+ if (args.req.headers.get(FIGMA_INTERNAL_FORWARDED_HAS_AUTH_BYPASS_HEADER) === '1') {
289
+ return true;
290
+ }
291
+ if (userJobsAccessRun) {
292
+ return userJobsAccessRun(args);
293
+ }
294
+ return !!args.req.user;
295
+ }
296
+ }
297
+ },
270
298
  ...isProduction && !config.logger ? {
271
299
  logger: {
272
300
  options: {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@payloadcms/figma",
3
- "version": "0.0.1-alpha.66",
3
+ "version": "0.0.1-alpha.68",
4
4
  "license": "SEE LICENSE IN LICENSE.md",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,17 +0,0 @@
1
- import type { CollectionConfig, Endpoint } from 'payload';
2
- import type { Strategy } from '../strategy/index.js';
3
- import type { CollectionOptions, PluginOptions } from '../types.js';
4
- interface Args {
5
- collection: CollectionConfig;
6
- collectionOptions: CollectionOptions;
7
- endpointSlug: string;
8
- pluginOptions: PluginOptions;
9
- strategy: Strategy;
10
- }
11
- /**
12
- * Auto-login endpoint for embedded contexts where the interactive OAuth flow
13
- * cannot run.
14
- */
15
- export declare const getTokenLoginEndpoint: ({ collection, collectionOptions, endpointSlug, pluginOptions, strategy, }: Args) => Endpoint;
16
- export {};
17
- //# sourceMappingURL=getTokenLoginEndpoint.d.ts.map
@@ -1,105 +0,0 @@
1
- import { formatAdminURL, getSafeRedirect } from 'payload/shared';
2
- import { createDebugLogger } from '../utilities/createDebugLogger.js';
3
- import { establishSession } from '../utilities/establishSession.js';
4
- import { exchangeCodeForAccessToken } from '../utilities/exchangeCodeForAccessToken.js';
5
- import { isAbsoluteURL } from '../utilities/isAbsoluteURL.js';
6
- /**
7
- * Auto-login endpoint for embedded contexts where the interactive OAuth flow
8
- * cannot run.
9
- */ export const getTokenLoginEndpoint = ({ collection, collectionOptions, endpointSlug, pluginOptions, strategy })=>({
10
- handler: async (req)=>{
11
- const { config } = req.payload;
12
- const adminRoute = config.routes?.admin || '/admin';
13
- const debugLogger = createDebugLogger(req.payload, pluginOptions.debug);
14
- const adminURL = formatAdminURL({
15
- adminRoute,
16
- serverURL: config.serverURL
17
- });
18
- const failedRedirect = formatAdminURL({
19
- adminRoute,
20
- path: '/login',
21
- serverURL: config.serverURL
22
- });
23
- try {
24
- await strategy.ensureMeta();
25
- const code = req.query.code;
26
- const redirectQuery = req.query.redirect;
27
- let redirectToUse = adminURL;
28
- if (typeof redirectQuery === 'string' && redirectQuery.length > 0) {
29
- const safeRedirect = getSafeRedirect({
30
- allowAbsoluteUrls: false,
31
- redirectTo: redirectQuery
32
- });
33
- if (safeRedirect && !isAbsoluteURL(safeRedirect)) {
34
- redirectToUse = `${config.serverURL ?? ''}${safeRedirect}`;
35
- }
36
- }
37
- if (typeof code !== 'string' || code.length === 0) {
38
- debugLogger.error({
39
- msg: 'token_login: missing code'
40
- });
41
- return Response.redirect(failedRedirect);
42
- }
43
- if (!strategy.meta?.token_endpoint) {
44
- debugLogger.error({
45
- msg: 'token_login: no token_endpoint discovered from identity provider'
46
- });
47
- return Response.redirect(failedRedirect);
48
- }
49
- debugLogger.info({
50
- msg: 'token_login: exchanging code for access token'
51
- });
52
- const tokenRes = await exchangeCodeForAccessToken({
53
- code,
54
- redirectUri: formatAdminURL({
55
- apiRoute: config.routes?.api || '/api',
56
- path: `/${collection.slug}/${endpointSlug}/token_login`,
57
- serverURL: config.serverURL
58
- }),
59
- strategy
60
- });
61
- const { access_token, error, error_description, expires_in } = tokenRes;
62
- if (error) {
63
- req.payload.logger.error({
64
- err: error,
65
- error_description,
66
- msg: 'token_login: error exchanging code for access token'
67
- });
68
- return Response.redirect(failedRedirect);
69
- }
70
- if (typeof access_token !== 'string' || access_token.length === 0) {
71
- req.payload.logger.error({
72
- msg: 'token_login: no access_token returned from identity provider'
73
- });
74
- return Response.redirect(failedRedirect);
75
- }
76
- await establishSession({
77
- accessToken: access_token,
78
- collection,
79
- collectionOptions,
80
- contentSystemId: config.custom.figma.contentSystemId,
81
- debugLogger,
82
- pluginOptions,
83
- refreshTokenExpiresIn: expires_in,
84
- req,
85
- strategy
86
- });
87
- return Response.redirect(redirectToUse);
88
- } catch (err) {
89
- req.payload.logger.error({
90
- err,
91
- msg: 'Error during token_login',
92
- path: req.pathname
93
- });
94
- return Response.json({
95
- error: 'Error during token_login'
96
- }, {
97
- status: 500
98
- });
99
- }
100
- },
101
- method: 'get',
102
- path: `/${endpointSlug}/token_login`
103
- });
104
-
105
- //# sourceMappingURL=getTokenLoginEndpoint.js.map