@withone/connect 0.8.3 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +158 -370
- package/dist/button.d.ts +9 -10
- package/dist/constants.d.ts +8 -4
- package/dist/index.cjs.js +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.esm.js +1 -1
- package/dist/next.cjs.js +104 -0
- package/dist/next.d.ts +36 -0
- package/dist/next.esm.js +98 -0
- package/dist/node.cjs.js +69 -0
- package/dist/node.d.ts +33 -0
- package/dist/node.esm.js +67 -0
- package/dist/platforms.d.ts +21 -0
- package/dist/react.cjs.js +1 -1
- package/dist/react.d.ts +2 -19
- package/dist/react.esm.js +1 -1
- package/dist/return.d.ts +11 -0
- package/dist/server/index.cjs.js +349 -0
- package/dist/server/index.d.ts +32 -0
- package/dist/server/index.esm.js +344 -0
- package/dist/server/oauth.d.ts +24 -0
- package/dist/server/types.d.ts +136 -0
- package/dist/svelte.cjs.js +1 -1
- package/dist/svelte.d.ts +14 -4
- package/dist/svelte.esm.js +1 -1
- package/dist/types.d.ts +80 -0
- package/dist/useOneConnect.d.ts +13 -2
- package/dist/vue.cjs.js +1 -1
- package/dist/vue.d.ts +20 -18
- package/dist/vue.esm.js +1 -1
- package/dist/wrapper-options.d.ts +20 -15
- package/package.json +37 -15
- package/skills/one-connect/SKILL.md +194 -0
- package/src/button.ts +106 -157
- package/src/constants.ts +10 -4
- package/src/index.ts +17 -2
- package/src/next.ts +133 -0
- package/src/node.ts +107 -0
- package/src/platforms.ts +89 -0
- package/src/react.ts +23 -66
- package/src/return.ts +30 -0
- package/src/server/index.ts +382 -0
- package/src/server/oauth.ts +98 -0
- package/src/server/types.ts +155 -0
- package/src/svelte.ts +15 -15
- package/src/types.ts +87 -0
- package/src/useOneConnect.ts +36 -69
- package/src/vue.ts +23 -29
- package/src/wrapper-options.ts +46 -23
- package/dist/index.umd.js +0 -1
- package/dist/types/index.d.ts +0 -92
- package/src/react-types.d.ts +0 -23
- package/src/svelte-types.d.ts +0 -27
- package/src/types/index.d.ts +0 -92
- package/src/vue-types.d.ts +0 -16
package/dist/react.d.ts
CHANGED
|
@@ -1,23 +1,6 @@
|
|
|
1
1
|
import type { CSSProperties, ReactElement } from "react";
|
|
2
|
-
import type
|
|
3
|
-
export interface ConnectButtonProps {
|
|
4
|
-
/** The app's own backend authorize route ("/api/one/authorize" is
|
|
5
|
-
* fine — relative resolves against the page). */
|
|
6
|
-
authorizeUrl: string;
|
|
7
|
-
/** Theme for One's hosted page (rides the URL fragment). */
|
|
8
|
-
appTheme?: "light" | "dark";
|
|
9
|
-
onSuccess?: () => void;
|
|
10
|
-
onError?: (error: string) => void;
|
|
11
|
-
onClose?: () => void;
|
|
12
|
-
label?: string;
|
|
13
|
-
variant?: "default" | "accent" | "block";
|
|
14
|
-
/** Matches the host page, not One's card. */
|
|
15
|
-
theme?: "light" | "dark";
|
|
16
|
-
platforms?: ConnectButtonPlatform[];
|
|
17
|
-
moreCount?: number;
|
|
18
|
-
description?: string;
|
|
19
|
-
accentColor?: string;
|
|
20
|
-
connectedLabel?: string;
|
|
2
|
+
import { type ConnectButtonProps as ConnectButtonCoreProps } from "@withone/connect";
|
|
3
|
+
export interface ConnectButtonProps extends ConnectButtonCoreProps {
|
|
21
4
|
className?: string;
|
|
22
5
|
style?: CSSProperties;
|
|
23
6
|
}
|
package/dist/react.esm.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{useRef as e,useEffect as t,createElement as n}from"react";function o(e,t,n){return(t=function(e){var t=function(e,t){if("object"!=typeof e||!e)return e;var n=e[Symbol.toPrimitive];if(void 0!==n){var o=n.call(e,t);if("object"!=typeof o)return o;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===t?String:Number)(e)}(e,"string");return"symbol"==typeof t?t:t+""}(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}const i="one_connect",r="one_connect_message";let c=!1;const l=e=>{let t=!1;if("undefined"!=typeof window&&!c){const o=new URLSearchParams(window.location.search),l=o.get(i);if("success"===l||"error"===l){var n;c=!0;const s=null!==(n=o.get(r))&&void 0!==n?n:void 0,a=()=>{const e=new URLSearchParams(window.location.search);(e.has(i)||e.has(r))&&(e.delete(i),e.delete(r),window.history.replaceState(null,"",window.location.pathname+(e.toString()?`?${e.toString()}`:"")+window.location.hash))};a();for(const e of[50,500,2e3])window.setTimeout(a,e);window.setTimeout(()=>{if(!t){t=!0;try{var n,o;"success"===l?null===(n=e.onSuccess)||void 0===n||n.call(e):null===(o=e.onError)||void 0===o||o.call(e,null!=s?s:"The connection was not completed.")}catch{}}},0)}}return{open:()=>{"undefined"!=typeof window&&window.location.assign((()=>{try{const t=new URL(e.authorize.url,"undefined"==typeof window?void 0:window.location.origin);return e.appTheme&&(t.hash=`one_theme=${e.appTheme}`),t.toString()}catch{return e.authorize.url}})())},close:e=>{}}},s="one-connect-button-styles",a="cubic-bezier(.2,.9,.25,1)",d={btnBg:"#0A0C0B",btnFg:"#ffffff",surface:"#ffffff",fg:"#0A0C0B",muted:"#6B7280",line:"#D1D5DB",shadow:"0 1px 2px rgba(10,12,11,.08), 0 4px 14px rgba(10,12,11,.06)"},p={btnBg:"#F2F5F3",btnFg:"#0A0C0B",surface:"#101312",fg:"#F2F5F3",muted:"#8A938E",line:"#2A302D",shadow:"0 1px 2px rgba(0,0,0,.4), 0 8px 24px rgba(0,0,0,.35)"},u="#CCFF00",h="#3FE3A5",m="#0A0C0B";function f(e){const t=e.trim().toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"");return t?`https://assets.withone.ai/connectors/${t}.svg`:""}const b=(e,t)=>{var n,o;!function(){if("undefined"==typeof document||document.getElementById(s))return;const e=document.createElement("style");e.id=s,e.textContent=[`.owcb{font:500 14.5px/1.2 inherit;font-family:inherit;letter-spacing:-.01em;cursor:pointer;border:0;display:inline-flex;align-items:center;gap:12px;padding:12px 16px 12px 14px;border-radius:12px;transition:transform .2s ${a},box-shadow .2s ${a},opacity .2s ${a}}`,".owcb:hover{transform:translateY(-1px)}",".owcb:active{transform:translateY(0)}",`.owcb:focus-visible{outline:2px solid ${h};outline-offset:3px}`,".owcb[disabled]{cursor:default;opacity:.75;transform:none}",".owcb-stack{display:inline-flex;align-items:center;flex-shrink:0}",`.owcb-chip{width:22px;height:22px;border-radius:6px;display:grid;place-items:center;overflow:hidden;margin-left:-7px;transition:margin-left .28s ${a};position:relative}`,".owcb-chip:first-child{margin-left:0}",".owcb-chip img{width:14px;height:14px;display:block;object-fit:contain}",".owcb-chip.owcb-more{font-family:ui-monospace,monospace;font-size:9px;font-weight:500;letter-spacing:-.02em}",".owcb:hover .owcb-chip{margin-left:-2px}",".owcb:hover .owcb-chip:first-child{margin-left:0}",".owcb-label{white-space:nowrap}",`.owcb-arrow{opacity:.5;transition:transform .2s ${a},opacity .2s ${a}}`,".owcb:hover .owcb-arrow{transform:translateX(2px);opacity:.8}",".owcb-spinner{width:16px;height:16px;border-radius:50%;border:2px solid currentColor;border-top-color:transparent;opacity:.7;animation:owcb-spin .7s linear infinite}","@keyframes owcb-spin{to{transform:rotate(360deg)}}",".owcb.owcb-block{display:flex;flex-direction:column;align-items:stretch;width:100%;max-width:420px;text-align:left;padding:16px;border-radius:14px;gap:0}",".owcb-block-top{display:flex;align-items:center;justify-content:space-between;gap:12px}",".owcb-block-title{font-size:15px;font-weight:600;letter-spacing:-.01em}",".owcb-block-sub{font-size:13px;line-height:1.5;margin-top:6px;font-weight:400}",".owcb-block-foot{margin-top:14px;padding-top:12px;display:flex;align-items:center;gap:6px;font-size:11.5px;font-weight:400}","@media (prefers-reduced-motion:reduce){.owcb,.owcb-chip,.owcb-arrow{transition:none}.owcb-spinner{animation-duration:1.4s}}"].join("\n"),document.head.appendChild(e)}();const i="dark"===t.theme?p:d,r=null!==(n=t.variant)&&void 0!==n?n:"default",c=null!==(o=t.label)&&void 0!==o?o:"Connect your apps";let b="idle",w=!1;const v=l({...t.connect,onSuccess:()=>{var e,n;y("connected"),null===(e=(n=t.connect).onSuccess)||void 0===e||e.call(n)},onError:e=>{var n,o;y("idle"),null===(n=(o=t.connect).onError)||void 0===n||n.call(o,e)},onClose:()=>{var e,n;y("idle"),null===(e=(n=t.connect).onClose)||void 0===e||e.call(n)}}),g=document.createElement("button");g.type="button",g.className="block"===r?"owcb owcb-block":"owcb",g.addEventListener("click",()=>{"idle"!==b&&"connected"!==b||(y("connecting"),v.open())});const x=()=>{var e,n,o;const l="accent"===r,s="block"===r,a=s?i.surface:l?null!==(e=t.accentColor)&&void 0!==e?e:u:i.btnBg,d=s?i.fg:l?m:i.btnFg,p=s?i.surface:a,w=m;g.style.background="connected"!==b||s?a:h,g.style.color="connected"!==b||s?d:m,g.style.boxShadow=s?`inset 0 0 0 1px ${i.line}`:"connected"===b?"none":l?`0 1px 2px rgba(10,12,11,.1), 0 6px 20px ${null!==(n=t.accentColor)&&void 0!==n?n:u}38`:i.shadow,g.disabled="connecting"===b,g.innerHTML="";const v=function(e,t,n){var o,i;const r=null!==(o=e.platforms)&&void 0!==o?o:[];if(0===r.length&&!e.moreCount)return null;const c=document.createElement("span");c.className="owcb-stack",c.setAttribute("aria-hidden","true");for(const e of r.slice(0,3)){var l;const n=document.createElement("span");n.className="owcb-chip",n.style.background="#ffffff",n.style.boxShadow=`0 0 0 1.5px ${t}`;const o=()=>{n.textContent=e.name.charAt(0).toUpperCase(),n.style.font="600 10px ui-monospace,monospace",n.style.color=m},i=null!==(l=e.imageUrl)&&void 0!==l?l:f(e.name);if(i){const e=document.createElement("img");e.alt="",e.src=i,e.addEventListener("error",()=>{e.remove(),o()}),n.appendChild(e)}else o();c.appendChild(n)}const s=(null!==(i=e.moreCount)&&void 0!==i?i:0)+Math.max(0,r.length-3);if(s>0){const e=document.createElement("span");e.className="owcb-chip owcb-more",e.style.background="#ffffff",e.style.boxShadow=`0 0 0 1.5px ${t}`,e.style.color=n,e.textContent=`+${s}`,c.appendChild(e)}return c}(t,p,w);if(s){var x;const e=document.createElement("span");e.className="owcb-block-top";const n=document.createElement("span");if(n.className="owcb-block-title",n.textContent="connecting"===b?"Connecting…":"connected"===b?null!==(x=t.connectedLabel)&&void 0!==x?x:"Connected":c,e.appendChild(n),"connecting"===b){const t=document.createElement("span");t.className="owcb-spinner",e.appendChild(t)}else v&&e.appendChild(v);if(g.appendChild(e),t.description&&"idle"===b){const e=document.createElement("span");e.className="owcb-block-sub",e.style.color=i.muted,e.textContent=t.description,g.appendChild(e)}const o=document.createElement("span");return o.className="owcb-block-foot",o.style.color=i.muted,o.style.borderTop=`1px solid ${i.line}`,o.innerHTML=`<span style="font-size:11px;color:${i.muted}">Secured by</span><span style="display:inline-flex;color:${i.fg}"><svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.4" style="flex-shrink:0"><circle cx="12" cy="12" r="8.5"/></svg></span><span style="font-size:12px;font-weight:600;letter-spacing:-0.02em;color:${i.fg}">one</span>`,void g.appendChild(o)}if("connecting"===b){const e=document.createElement("span");e.className="owcb-spinner",g.appendChild(e)}else if("connected"===b){const e=document.createElement("span");e.innerHTML='<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:16px;height:16px"><path d="M3 8.5l3.5 3.5L13 5"/></svg>',e.style.display="inline-flex",g.appendChild(e)}else v&&g.appendChild(v);const y=document.createElement("span");if(y.className="owcb-label",y.textContent="connecting"===b?"Connecting…":"connected"===b?null!==(o=t.connectedLabel)&&void 0!==o?o:"Connected":c,g.appendChild(y),"idle"===b){const e=document.createElement("span");e.innerHTML='<svg class="owcb-arrow" width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3l5 5-5 5"/></svg>',e.style.display="inline-flex",g.appendChild(e)}},y=e=>{w||(b=e,x())};return x(),e.appendChild(g),{setState:y,destroy:()=>{w=!0,v.close({keepResult:!0}),g.remove()}}};function w(o){var i;const r=e(null),c=e(null),l=e({onSuccess:o.onSuccess,onError:o.onError,onClose:o.onClose});l.current={onSuccess:o.onSuccess,onError:o.onError,onClose:o.onClose};const s=JSON.stringify(null!==(i=o.platforms)&&void 0!==i?i:[]);return t(()=>{if(!r.current)return;const e={connect:{authorize:{url:o.authorizeUrl},appTheme:o.appTheme,onSuccess:()=>{var e,t;return null===(e=(t=l.current).onSuccess)||void 0===e?void 0:e.call(t)},onError:e=>{var t,n;return null===(t=(n=l.current).onError)||void 0===t?void 0:t.call(n,e)},onClose:()=>{var e,t;return null===(e=(t=l.current).onClose)||void 0===e?void 0:e.call(t)}},label:o.label,variant:o.variant,theme:o.theme,platforms:o.platforms,moreCount:o.moreCount,description:o.description,accentColor:o.accentColor,connectedLabel:o.connectedLabel};return c.current=b(r.current,e),()=>{var e;null===(e=c.current)||void 0===e||e.destroy(),c.current=null}},[o.authorizeUrl,o.appTheme,o.label,o.variant,o.theme,s,o.moreCount,o.description,o.accentColor,o.connectedLabel]),n("div",{ref:r,className:o.className,style:o.style})}!function(){if("undefined"!=typeof window&&"undefined"!=typeof customElements&&!customElements.get("one-connect-button")){class e extends HTMLElement{constructor(...e){super(...e),o(this,"onSuccess",null),o(this,"onError",null),o(this,"onClose",null),o(this,"handle",null)}connectedCallback(){this.mount()}disconnectedCallback(){var e;null===(e=this.handle)||void 0===e||e.destroy(),this.handle=null}attributeChangedCallback(){this.isConnected&&this.handle&&this.mount()}mount(){var e,t,n,o,i,r,c,l;null===(e=this.handle)||void 0===e||e.destroy(),this.handle=null;const s=this.getAttribute("authorize-url");if(!s)return;let a;const d=this.getAttribute("platforms");if(d)try{const e=JSON.parse(d);Array.isArray(e)&&(a=e)}catch{}const p=this.getAttribute("more-count"),u=p?parseInt(p,10):void 0;this.handle=b(this,{connect:{authorize:{url:s},appTheme:null!==(t=this.getAttribute("app-theme"))&&void 0!==t?t:void 0,onSuccess:()=>{var e;null===(e=this.onSuccess)||void 0===e||e.call(this),this.dispatchEvent(new CustomEvent("success"))},onError:e=>{var t;null===(t=this.onError)||void 0===t||t.call(this,e),this.dispatchEvent(new CustomEvent("error",{detail:e}))},onClose:()=>{var e;null===(e=this.onClose)||void 0===e||e.call(this),this.dispatchEvent(new CustomEvent("close"))}},label:null!==(n=this.getAttribute("label"))&&void 0!==n?n:void 0,variant:null!==(o=this.getAttribute("variant"))&&void 0!==o?o:void 0,theme:null!==(i=this.getAttribute("theme"))&&void 0!==i?i:void 0,platforms:a,moreCount:Number.isFinite(u)?u:void 0,description:null!==(r=this.getAttribute("description"))&&void 0!==r?r:void 0,accentColor:null!==(c=this.getAttribute("accent-color"))&&void 0!==c?c:void 0,connectedLabel:null!==(l=this.getAttribute("connected-label"))&&void 0!==l?l:void 0})}}o(e,"observedAttributes",["authorize-url","app-theme","label","variant","theme","platforms","more-count","description","accent-color","connected-label"]),customElements.define("one-connect-button",e)}}();export{w as ConnectButton};
|
|
1
|
+
import{useRef as r,useEffect as n,createElement as o}from"react";import{propsIdentity as c,mountConnectButton as e,optionsFromProps as u}from"@withone/connect";function t(t){const s=r(null),l=r(null),i=r({onSuccess:t.onSuccess,onError:t.onError});i.current={onSuccess:t.onSuccess,onError:t.onError};const a=c(t);return n(()=>{if(s.current)return l.current=e(s.current,u(t,{onSuccess:()=>{var r,n;return null===(r=(n=i.current).onSuccess)||void 0===r?void 0:r.call(n)},onError:r=>{var n,o;return null===(n=(o=i.current).onError)||void 0===n?void 0:n.call(o,r)}})),()=>{var r;null===(r=l.current)||void 0===r||r.destroy(),l.current=null}},[a]),o("div",{ref:s,className:t.className,style:t.style})}export{t as ConnectButton};
|
package/dist/return.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { OneConnectReturn } from "./types";
|
|
2
|
+
/** Reads the outcome the callback route put on the return URL. */
|
|
3
|
+
export declare function parseReturn(search: string): OneConnectReturn | null;
|
|
4
|
+
/** The same URL without the return params, so a refresh does not
|
|
5
|
+
* re-fire the callbacks. */
|
|
6
|
+
export declare function stripReturnParams(location: {
|
|
7
|
+
pathname: string;
|
|
8
|
+
search: string;
|
|
9
|
+
hash: string;
|
|
10
|
+
}): string;
|
|
11
|
+
export declare function hasReturnParams(search: string): boolean;
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var node_crypto = require('node:crypto');
|
|
4
|
+
|
|
5
|
+
/** Query params the app's callback route puts on its final redirect.
|
|
6
|
+
* The SDK reads them off the page URL when the tab comes home, so
|
|
7
|
+
* there is no completion page to build. */
|
|
8
|
+
const RETURN_STATUS_PARAM = "one_connect";
|
|
9
|
+
const RETURN_MESSAGE_PARAM = "one_connect_message";
|
|
10
|
+
|
|
11
|
+
/** One's production API. Point `oneApiUrl` elsewhere for development. */
|
|
12
|
+
const DEFAULT_ONE_API_URL = "https://api.withone.ai";
|
|
13
|
+
|
|
14
|
+
/** One cookie per flow, named by its state, so a retry or a second tab
|
|
15
|
+
* never overwrites the verifier of a flow still in progress. */
|
|
16
|
+
const TX_COOKIE_PREFIX = "one_tx_";
|
|
17
|
+
|
|
18
|
+
/** One gives the consent 10 minutes and then the code 10 minutes, so a
|
|
19
|
+
* legitimate callback can arrive up to twenty minutes after the start.
|
|
20
|
+
* Thirty minutes leaves a margin. */
|
|
21
|
+
const TX_COOKIE_MAX_AGE_SECONDS = 1800;
|
|
22
|
+
const DEFAULT_SCOPES = ["user:connections:read", "user:connections:write", "org:connections:read", "org:connections:write", "project:connections:read", "project:connections:write"];
|
|
23
|
+
function createState() {
|
|
24
|
+
return node_crypto.randomBytes(16).toString("hex");
|
|
25
|
+
}
|
|
26
|
+
function createPkceVerifier() {
|
|
27
|
+
return node_crypto.randomBytes(32).toString("base64url");
|
|
28
|
+
}
|
|
29
|
+
function pkceChallenge(verifier) {
|
|
30
|
+
return node_crypto.createHash("sha256").update(verifier).digest("base64url");
|
|
31
|
+
}
|
|
32
|
+
function txCookieName(state) {
|
|
33
|
+
return `${TX_COOKIE_PREFIX}${state}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The cookie scoped to the routes that need it: the directory of the
|
|
37
|
+
* callback path, which is also where the authorize route lives. */
|
|
38
|
+
function txCookie(state, verifier, redirectUri) {
|
|
39
|
+
const url = new URL(redirectUri);
|
|
40
|
+
const path = url.pathname.replace(/\/[^/]*$/, "") || "/";
|
|
41
|
+
return {
|
|
42
|
+
name: txCookieName(state),
|
|
43
|
+
value: verifier,
|
|
44
|
+
options: {
|
|
45
|
+
httpOnly: true,
|
|
46
|
+
secure: url.protocol === "https:",
|
|
47
|
+
sameSite: "lax",
|
|
48
|
+
maxAge: TX_COOKIE_MAX_AGE_SECONDS,
|
|
49
|
+
path
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function basicAuthorization(clientId, clientSecret) {
|
|
54
|
+
return `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The tenant headers a grant needs on every call. A grant made into an
|
|
58
|
+
* organization or project lives there; One resolves the tenant from
|
|
59
|
+
* these headers, and without them the call runs in the user's personal
|
|
60
|
+
* space. The access token's claims name the tenant, so echo them. */
|
|
61
|
+
function tenancyHeaders(accessToken) {
|
|
62
|
+
try {
|
|
63
|
+
const payload = JSON.parse(Buffer.from(accessToken.split(".")[1], "base64url").toString());
|
|
64
|
+
const headers = {};
|
|
65
|
+
const organizationId = payload.organization_ids?.[0];
|
|
66
|
+
const projectId = payload.project_ids?.[0];
|
|
67
|
+
if (organizationId) headers["X-One-Organization-Id"] = organizationId;
|
|
68
|
+
if (projectId) headers["X-One-Project-Id"] = projectId;
|
|
69
|
+
return headers;
|
|
70
|
+
} catch {
|
|
71
|
+
return {};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The scopes the token was granted, from its claims. Display only. */
|
|
76
|
+
function tokenScopes(accessToken) {
|
|
77
|
+
try {
|
|
78
|
+
const payload = JSON.parse(Buffer.from(accessToken.split(".")[1], "base64url").toString());
|
|
79
|
+
return (payload.scope ?? "").split(" ").filter(Boolean);
|
|
80
|
+
} catch {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Types for `@withone/connect/server`: the half of the SDK that runs on
|
|
87
|
+
* the app's server and holds the client secret.
|
|
88
|
+
*/
|
|
89
|
+
|
|
90
|
+
/** What the app stores per user after the exchange. */
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Where the app keeps each user's tokens: its database, a cache, an
|
|
94
|
+
* encrypted cookie. The SDK never sees a token outside these three
|
|
95
|
+
* calls. `userId` is the app's own id for its user.
|
|
96
|
+
*/
|
|
97
|
+
|
|
98
|
+
/** The transaction cookie the authorize leg sets and the callback reads. */
|
|
99
|
+
|
|
100
|
+
/** One connection the grant reaches, with the access it confers. */
|
|
101
|
+
|
|
102
|
+
/** One catalog action for a platform. */
|
|
103
|
+
|
|
104
|
+
class OneConnectError extends Error {
|
|
105
|
+
constructor(code, message, status) {
|
|
106
|
+
super(message);
|
|
107
|
+
this.name = "OneConnectError";
|
|
108
|
+
this.code = code;
|
|
109
|
+
this.status = status;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* `@withone/connect/server`: the half of One Connect that runs on the
|
|
115
|
+
* app's server. It starts the flow, completes it, keeps the tokens fresh
|
|
116
|
+
* and makes every call with the grant. The client secret never leaves
|
|
117
|
+
* here.
|
|
118
|
+
*
|
|
119
|
+
* const oneConnect = createOneConnect({ clientId, clientSecret,
|
|
120
|
+
* redirectUri, permissionSet, tokenStore });
|
|
121
|
+
*
|
|
122
|
+
* // in the authorize route
|
|
123
|
+
* const { redirectUrl, cookie } = oneConnect.startAuthorization({ loginHint });
|
|
124
|
+
* // in the callback route
|
|
125
|
+
* const result = await oneConnect.completeAuthorization({ userId, url, getCookie });
|
|
126
|
+
* // afterwards
|
|
127
|
+
* const rows = await oneConnect.listConnections(userId);
|
|
128
|
+
* const reply = await oneConnect.runAction(userId, { connectionKey, actionId, method, path });
|
|
129
|
+
*
|
|
130
|
+
* The Next.js and Node adapters turn the first two into route handlers.
|
|
131
|
+
*/
|
|
132
|
+
|
|
133
|
+
/** Refresh this long before expiry, so a call never races the clock. */
|
|
134
|
+
const REFRESH_MARGIN_MS = 60_000;
|
|
135
|
+
const CATALOG_PAGE_SIZE = 100;
|
|
136
|
+
const CATALOG_MAX_PAGES = 20;
|
|
137
|
+
function createOneConnect(config) {
|
|
138
|
+
const oneApiUrl = (config.oneApiUrl ?? DEFAULT_ONE_API_URL).replace(/\/+$/, "");
|
|
139
|
+
const authorizeUrl = `${oneApiUrl}/oauth/authorize`;
|
|
140
|
+
const tokenUrl = `${oneApiUrl}/oauth/token`;
|
|
141
|
+
const apiUrl = `${oneApiUrl}/v1`;
|
|
142
|
+
const returnTo = config.returnTo ?? "/";
|
|
143
|
+
const scopes = config.scopes ?? DEFAULT_SCOPES;
|
|
144
|
+
const {
|
|
145
|
+
tokenStore
|
|
146
|
+
} = config;
|
|
147
|
+
|
|
148
|
+
/** One refresh in flight per user: two concurrent refreshes with the
|
|
149
|
+
* same refresh token trip One's reuse detection. */
|
|
150
|
+
const refreshing = new Map();
|
|
151
|
+
const returnUrl = (status, message) => {
|
|
152
|
+
const url = new URL(returnTo, config.redirectUri);
|
|
153
|
+
url.searchParams.set(RETURN_STATUS_PARAM, status);
|
|
154
|
+
if (message) url.searchParams.set(RETURN_MESSAGE_PARAM, message);
|
|
155
|
+
return url.toString();
|
|
156
|
+
};
|
|
157
|
+
const exchange = async body => {
|
|
158
|
+
const response = await fetch(tokenUrl, {
|
|
159
|
+
method: "POST",
|
|
160
|
+
headers: {
|
|
161
|
+
Authorization: basicAuthorization(config.clientId, config.clientSecret),
|
|
162
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
163
|
+
},
|
|
164
|
+
body
|
|
165
|
+
});
|
|
166
|
+
if (!response.ok) {
|
|
167
|
+
throw new OneConnectError("request_failed", `One refused the token request (HTTP ${response.status}).`, response.status);
|
|
168
|
+
}
|
|
169
|
+
return await response.json();
|
|
170
|
+
};
|
|
171
|
+
const toTokens = response => ({
|
|
172
|
+
accessToken: response.access_token,
|
|
173
|
+
refreshToken: response.refresh_token,
|
|
174
|
+
expiresAt: Date.now() + response.expires_in * 1000
|
|
175
|
+
});
|
|
176
|
+
const startAuthorization = (input = {}) => {
|
|
177
|
+
const state = createState();
|
|
178
|
+
const verifier = createPkceVerifier();
|
|
179
|
+
const url = new URL(authorizeUrl);
|
|
180
|
+
url.searchParams.set("client_id", config.clientId);
|
|
181
|
+
url.searchParams.set("redirect_uri", config.redirectUri);
|
|
182
|
+
url.searchParams.set("response_type", "code");
|
|
183
|
+
url.searchParams.set("scope", scopes.join(" "));
|
|
184
|
+
url.searchParams.set("state", state);
|
|
185
|
+
url.searchParams.set("code_challenge", pkceChallenge(verifier));
|
|
186
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
187
|
+
if (config.permissionSet) url.searchParams.set("permission_set", config.permissionSet);
|
|
188
|
+
if (input.loginHint) url.searchParams.set("login_hint", input.loginHint);
|
|
189
|
+
return {
|
|
190
|
+
redirectUrl: url.toString(),
|
|
191
|
+
cookie: txCookie(state, verifier, config.redirectUri)
|
|
192
|
+
};
|
|
193
|
+
};
|
|
194
|
+
const completeAuthorization = async input => {
|
|
195
|
+
const params = new URL(input.url).searchParams;
|
|
196
|
+
const code = params.get("code");
|
|
197
|
+
const state = params.get("state");
|
|
198
|
+
const oauthError = params.get("error");
|
|
199
|
+
const cookieName = state ? txCookieName(state) : undefined;
|
|
200
|
+
const verifier = cookieName ? input.getCookie(cookieName) : undefined;
|
|
201
|
+
const fail = (outcome, message) => ({
|
|
202
|
+
outcome,
|
|
203
|
+
message,
|
|
204
|
+
redirectUrl: returnUrl("error", message),
|
|
205
|
+
clearCookieName: cookieName
|
|
206
|
+
});
|
|
207
|
+
if (oauthError === "access_denied") return fail("declined", "You cancelled the request.");
|
|
208
|
+
if (oauthError) return fail("failed", `One reported an error: ${oauthError}.`);
|
|
209
|
+
// The returned state names its own cookie. No cookie means a forged
|
|
210
|
+
// or stale state; the code is never exchanged in that case.
|
|
211
|
+
if (!code || !state || !verifier) return fail("failed", "The sign-in attempt expired or was tampered with.");
|
|
212
|
+
try {
|
|
213
|
+
const tokens = toTokens(await exchange(new URLSearchParams({
|
|
214
|
+
grant_type: "authorization_code",
|
|
215
|
+
code,
|
|
216
|
+
redirect_uri: config.redirectUri,
|
|
217
|
+
code_verifier: verifier
|
|
218
|
+
})));
|
|
219
|
+
await tokenStore.saveTokens(input.userId, tokens);
|
|
220
|
+
} catch (error) {
|
|
221
|
+
const status = error instanceof OneConnectError ? error.status : undefined;
|
|
222
|
+
return fail("failed", status ? `One rejected the code exchange (HTTP ${status}).` : "One could not be reached to complete the connection.");
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
outcome: "connected",
|
|
226
|
+
redirectUrl: returnUrl("success"),
|
|
227
|
+
clearCookieName: cookieName
|
|
228
|
+
};
|
|
229
|
+
};
|
|
230
|
+
const refreshTokens = userId => {
|
|
231
|
+
const inFlight = refreshing.get(userId);
|
|
232
|
+
if (inFlight) return inFlight;
|
|
233
|
+
const job = (async () => {
|
|
234
|
+
const current = await tokenStore.loadTokens(userId);
|
|
235
|
+
if (!current) throw new OneConnectError("not_connected", "This user is not connected.");
|
|
236
|
+
try {
|
|
237
|
+
const next = toTokens(await exchange(new URLSearchParams({
|
|
238
|
+
grant_type: "refresh_token",
|
|
239
|
+
refresh_token: current.refreshToken
|
|
240
|
+
})));
|
|
241
|
+
// Both tokens: One rotates the pair on every refresh.
|
|
242
|
+
await tokenStore.saveTokens(userId, next);
|
|
243
|
+
return next;
|
|
244
|
+
} catch (error) {
|
|
245
|
+
// The family is dead: revoked, expired or reused. Keeping the
|
|
246
|
+
// pair would only fail again; the user has to reconnect.
|
|
247
|
+
await tokenStore.clearTokens(userId);
|
|
248
|
+
const status = error instanceof OneConnectError ? error.status : undefined;
|
|
249
|
+
throw new OneConnectError("refresh_failed", "The connection to One has expired or was revoked. Ask the user to connect again.", status);
|
|
250
|
+
} finally {
|
|
251
|
+
refreshing.delete(userId);
|
|
252
|
+
}
|
|
253
|
+
})();
|
|
254
|
+
refreshing.set(userId, job);
|
|
255
|
+
return job;
|
|
256
|
+
};
|
|
257
|
+
const getAccessToken = async userId => {
|
|
258
|
+
const tokens = await tokenStore.loadTokens(userId);
|
|
259
|
+
if (!tokens) throw new OneConnectError("not_connected", "This user is not connected.");
|
|
260
|
+
if (Date.now() < tokens.expiresAt - REFRESH_MARGIN_MS) return tokens.accessToken;
|
|
261
|
+
return (await refreshTokens(userId)).accessToken;
|
|
262
|
+
};
|
|
263
|
+
const oneFetch = async (userId, path, init = {}) => {
|
|
264
|
+
const accessToken = await getAccessToken(userId);
|
|
265
|
+
const headers = new Headers(init.headers);
|
|
266
|
+
headers.set("Authorization", `Bearer ${accessToken}`);
|
|
267
|
+
for (const [name, value] of Object.entries(tenancyHeaders(accessToken))) headers.set(name, value);
|
|
268
|
+
return fetch(`${apiUrl}${path.startsWith("/") ? path : `/${path}`}`, {
|
|
269
|
+
...init,
|
|
270
|
+
headers
|
|
271
|
+
});
|
|
272
|
+
};
|
|
273
|
+
const listConnections = async userId => {
|
|
274
|
+
const response = await oneFetch(userId, "/connections/reachable");
|
|
275
|
+
if (!response.ok) {
|
|
276
|
+
throw new OneConnectError("request_failed", `One refused the connections request (HTTP ${response.status}).`, response.status);
|
|
277
|
+
}
|
|
278
|
+
const body = await response.json();
|
|
279
|
+
return body.rows ?? [];
|
|
280
|
+
};
|
|
281
|
+
const listActions = async (userId, platform) => {
|
|
282
|
+
const pageUrl = page => `/knowledge?connectionPlatform=${encodeURIComponent(platform)}&limit=${CATALOG_PAGE_SIZE}&page=${page}`;
|
|
283
|
+
const slim = rows => rows.map(row => ({
|
|
284
|
+
_id: String(row._id ?? ""),
|
|
285
|
+
title: String(row.title ?? ""),
|
|
286
|
+
method: String(row.method ?? ""),
|
|
287
|
+
path: String(row.path ?? "")
|
|
288
|
+
}));
|
|
289
|
+
const first = await oneFetch(userId, pageUrl(1));
|
|
290
|
+
if (!first.ok) {
|
|
291
|
+
throw new OneConnectError("request_failed", `One refused the catalog request (HTTP ${first.status}).`, first.status);
|
|
292
|
+
}
|
|
293
|
+
const page1 = await first.json();
|
|
294
|
+
const pages = Math.min(Math.max(page1.pages ?? 1, 1), CATALOG_MAX_PAGES);
|
|
295
|
+
const rest = await Promise.all(Array.from({
|
|
296
|
+
length: pages - 1
|
|
297
|
+
}, (_, index) => oneFetch(userId, pageUrl(index + 2)).then(response => response.ok ? response.json() : null).catch(() => null)));
|
|
298
|
+
return slim([page1, ...rest].flatMap(page => page?.rows ?? []));
|
|
299
|
+
};
|
|
300
|
+
const runAction = async (userId, input) => {
|
|
301
|
+
const method = input.method.toUpperCase();
|
|
302
|
+
const query = input.query ? `?${new URLSearchParams(input.query)}` : "";
|
|
303
|
+
const headers = {
|
|
304
|
+
"x-one-connection-key": input.connectionKey,
|
|
305
|
+
"x-one-action-id": input.actionId
|
|
306
|
+
};
|
|
307
|
+
const hasBody = method !== "GET" && method !== "HEAD" && input.body !== undefined;
|
|
308
|
+
if (hasBody) headers["Content-Type"] = "application/json";
|
|
309
|
+
const response = await oneFetch(userId, `/passthrough${input.path}${query}`, {
|
|
310
|
+
method,
|
|
311
|
+
headers,
|
|
312
|
+
body: hasBody ? JSON.stringify(input.body) : undefined
|
|
313
|
+
});
|
|
314
|
+
const text = await response.text();
|
|
315
|
+
let data = text;
|
|
316
|
+
try {
|
|
317
|
+
data = JSON.parse(text);
|
|
318
|
+
} catch {
|
|
319
|
+
/* the provider answered with something other than JSON */
|
|
320
|
+
}
|
|
321
|
+
// One's own refusals carry a correlationId; a provider's error does
|
|
322
|
+
// not. A One-shaped 403 is the grant working, not the provider.
|
|
323
|
+
const blockedByGrant = !response.ok && typeof data === "object" && data !== null && "correlationId" in data;
|
|
324
|
+
return {
|
|
325
|
+
status: response.status,
|
|
326
|
+
ok: response.ok,
|
|
327
|
+
blockedByGrant,
|
|
328
|
+
data
|
|
329
|
+
};
|
|
330
|
+
};
|
|
331
|
+
return {
|
|
332
|
+
startAuthorization,
|
|
333
|
+
completeAuthorization,
|
|
334
|
+
isConnected: async userId => (await tokenStore.loadTokens(userId)) !== null,
|
|
335
|
+
getAccessToken,
|
|
336
|
+
getTokens: userId => tokenStore.loadTokens(userId),
|
|
337
|
+
refreshTokens,
|
|
338
|
+
disconnect: userId => tokenStore.clearTokens(userId),
|
|
339
|
+
listConnections,
|
|
340
|
+
listActions,
|
|
341
|
+
runAction,
|
|
342
|
+
fetch: oneFetch
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
exports.OneConnectError = OneConnectError;
|
|
347
|
+
exports.createOneConnect = createOneConnect;
|
|
348
|
+
exports.tenancyHeaders = tenancyHeaders;
|
|
349
|
+
exports.tokenScopes = tokenScopes;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type CompleteAuthorizationInput, type CompleteAuthorizationResult, type OneConnectServerConfig, type OneConnectTokens, type PlatformAction, type ReachableConnection, type RunActionInput, type RunActionResult, type StartAuthorizationInput, type StartAuthorizationResult } from "./types";
|
|
2
|
+
export * from "./types";
|
|
3
|
+
export { tenancyHeaders, tokenScopes } from "./oauth";
|
|
4
|
+
export interface OneConnect {
|
|
5
|
+
/** The authorize leg: where to send the browser and the cookie to set. */
|
|
6
|
+
startAuthorization: (input?: StartAuthorizationInput) => StartAuthorizationResult;
|
|
7
|
+
/** The callback leg: verifies state, exchanges the code, stores the
|
|
8
|
+
* tokens, and says where to send the browser next. Never throws for
|
|
9
|
+
* a failed flow; read `outcome`. */
|
|
10
|
+
completeAuthorization: (input: CompleteAuthorizationInput) => Promise<CompleteAuthorizationResult>;
|
|
11
|
+
/** Whether the user has tokens stored. */
|
|
12
|
+
isConnected: (userId: string) => Promise<boolean>;
|
|
13
|
+
/** A live access token, refreshed first when it is about to expire. */
|
|
14
|
+
getAccessToken: (userId: string) => Promise<string>;
|
|
15
|
+
/** The stored tokens, for display. Null when not connected. */
|
|
16
|
+
getTokens: (userId: string) => Promise<OneConnectTokens | null>;
|
|
17
|
+
/** Forces a refresh now. */
|
|
18
|
+
refreshTokens: (userId: string) => Promise<OneConnectTokens>;
|
|
19
|
+
/** Drops the app's copy of the tokens. The user revokes the grant
|
|
20
|
+
* itself from their One dashboard. */
|
|
21
|
+
disconnect: (userId: string) => Promise<void>;
|
|
22
|
+
/** The connections the grant reaches, each with its access. */
|
|
23
|
+
listConnections: (userId: string) => Promise<ReachableConnection[]>;
|
|
24
|
+
/** Every catalog action of a platform. What exists, not what is
|
|
25
|
+
* permitted; the grant decides that when the action runs. */
|
|
26
|
+
listActions: (userId: string, platform: string) => Promise<PlatformAction[]>;
|
|
27
|
+
/** Runs one action through One with the grant. */
|
|
28
|
+
runAction: (userId: string, input: RunActionInput) => Promise<RunActionResult>;
|
|
29
|
+
/** Any authenticated request to One's /v1 API, headers handled. */
|
|
30
|
+
fetch: (userId: string, path: string, init?: RequestInit) => Promise<Response>;
|
|
31
|
+
}
|
|
32
|
+
export declare function createOneConnect(config: OneConnectServerConfig): OneConnect;
|