@payloadcms/figma 0.0.1-alpha.66 → 0.0.1-alpha.67
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/dist/auth/crypto-utils.js +47 -10
- package/dist/oauth/components/LoginButton/iframeAutoLogin.d.ts +63 -0
- package/dist/oauth/components/LoginButton/iframeAutoLogin.js +153 -0
- package/dist/oauth/components/LoginButton/index.js +86 -5
- package/dist/oauth/components/LoginButton/index.scss +25 -0
- package/dist/oauth/components/LogoutButton/index.js +16 -4
- package/dist/oauth/endpoints/getLoginEndpoint.js +15 -2
- package/dist/oauth/endpoints/getMetaEndpoint.js +18 -3
- package/dist/oauth/endpoints/getRedirectToLoginEndpoint.js +11 -2
- package/dist/oauth/index.js +0 -8
- package/dist/oauth/types.d.ts +7 -0
- package/dist/oauth/utilities/figmaHostnames.d.ts +3 -0
- package/dist/oauth/utilities/figmaHostnames.js +26 -0
- package/dist/oauth/utilities/getAuthorizeURL.d.ts +54 -1
- package/dist/oauth/utilities/getAuthorizeURL.js +44 -2
- package/package.json +1 -1
- package/dist/oauth/endpoints/getTokenLoginEndpoint.d.ts +0 -17
- package/dist/oauth/endpoints/getTokenLoginEndpoint.js +0 -105
|
@@ -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
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
134
|
+
shouldRenderButton,
|
|
75
135
|
endpointSlug
|
|
76
136
|
]);
|
|
77
|
-
if (
|
|
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
|
|
20
|
+
let origin = `${protocol}//${domain}`;
|
|
16
21
|
if (port) {
|
|
17
|
-
|
|
22
|
+
origin += `:${port}`;
|
|
18
23
|
}
|
|
19
|
-
|
|
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:
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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',
|
package/dist/oauth/index.js
CHANGED
|
@@ -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,
|
package/dist/oauth/types.d.ts
CHANGED
|
@@ -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,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
|
|
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
|
|
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
|
package/package.json
CHANGED
|
@@ -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
|