Connecting a third-party provider (OAuth)
An App that connects an external system — a payroll vendor, a SaaS tool, DATEV — runs an OAuth authorization code flow against that provider. Factorial Code runs both redirect legs for you.
For the form side of it — the Connect button and how the result reaches the form — see Connecting an external account. This page is about the authorization itself.
Why the platform owns it
Section titled “Why the platform owns it”Almost every provider requires redirect URIs to be registered in advance and then matches them
exactly. But a webhook URL carries the workspace slug, and each customer’s installation of your App
is its own deploy-{deployId} workspace:
https://code.factorialhr.com/platform/api/deploy-9f3c1ab8d2e4/webhooks/my-callbackThat URL is different for every customer, so it can never be the registered one. Registering one customer’s URL and routing everybody through it is not a workaround — it makes one installation a dependency of all the others.
So the platform owns a single fixed redirect URI, and dispatches each callback to the installation it belongs to.
Register this with your provider
Section titled “Register this with your provider”https://code.factorialhr.com/platform/api/oauth/callbackOne string, for every App and every customer. Nothing per-workspace to keep in step, and no query parameters — providers compare the registered URI exactly.
Starting a flow
Section titled “Starting a flow”Call fcode.oauth.start() from the process that renders your Connect button — typically a
pre-render process — and give the form’s OAuth
widget the authorizationUrl it returns.
const flow = await fcode.oauth.start({ authorizeUrl: "https://login.example.com/authorize", clientId: fcode.env.PROVIDER_CLIENT_ID, scope: ["openid", "offline_access", "payroll:read"], onComplete: "oauth-callback", data: { companyId, legalEntityId },});
return { variables: { authorizeUrl: flow.authorizationUrl } };flow = fcode.oauth.start( authorize_url="https://login.example.com/authorize", client_id=fcode.env.PROVIDER_CLIENT_ID, scope=["openid", "offline_access", "payroll:read"], on_complete="oauth-callback", data={"companyId": company_id, "legalEntityId": legal_entity_id},)
return {"variables": {"authorizeUrl": flow.authorization_url}}| Option | |
|---|---|
authorizeUrl | The provider’s authorization endpoint. Required. |
clientId | The client id registered with the provider. Required. |
onComplete | The process to run when the provider redirects back. Required, and it must already exist — starting a flow that names a missing process fails here, at render time, rather than after a customer has signed in. |
scope | A list of scopes, or one already-joined string. |
data | Anything this flow should carry to onComplete. Opaque to the platform and handed back untouched. |
extraParams | Provider-specific authorization parameters (a nonce, an audience). They cannot override the ones the protocol depends on. |
versionTag | Pins onComplete to a version, so a flow started before a release still lands on the code it was started with. |
pkce | On by default. Turn it off only for a provider that cannot cope with it. |
A flow is valid for 15 minutes, which is enough to survive a provider sign-in with 2FA. The authorization URL can be opened more than once — a form re-render or a double click reaches the provider again with the same challenge — but it completes only once.
Receiving the callback
Section titled “Receiving the callback”The platform runs your onComplete process itself. It does not need a webhook, public or
otherwise: nobody is calling it over HTTP, so there is nothing to expose and nothing to
authenticate.
Everything the token exchange needs is already in the parameters:
const { code, codeVerifier, redirectUri, data, state, error } = fcode.context.parameters;
if (error) { return redirectToForm({ status: "error", message: "Authorization was refused." });}
const tokens = await exchange({ code, code_verifier: codeVerifier, redirect_uri: redirectUri, // the provider compares this byte for byte client_id: fcode.env.PROVIDER_CLIENT_ID, client_secret: fcode.env.PROVIDER_CLIENT_SECRET,});
await fcode.datastore.set(`tokens.${data.companyId}`, JSON.stringify(tokens));
return { status: 302, headers: { Location: "https://code.factorialhr.com/sdk/oauth-callback.html" + "?status=success&value=" + encodeURIComponent(data.companyId) + "&state=" + encodeURIComponent(state), // the form only accepts a completion that echoes it },};p = fcode.context.parameters
if p.get("error"): return redirect_to_form({"status": "error", "message": "Authorization was refused."})
tokens = exchange( code=p["code"], code_verifier=p["codeVerifier"], redirect_uri=p["redirectUri"], # the provider compares this byte for byte client_id=fcode.env.PROVIDER_CLIENT_ID, client_secret=fcode.env.PROVIDER_CLIENT_SECRET,)
fcode.datastore.set(f"tokens.{p['data']['companyId']}", json.dumps(tokens))
return { "status": 302, "headers": { "Location": "https://code.factorialhr.com/sdk/oauth-callback.html" f"?status=success&value={p['data']['companyId']}" f"&state={p['state']}", # the form only accepts a completion that echoes it },}| Parameter | |
|---|---|
code | The authorization code. Absent when the provider refused. |
codeVerifier | The PKCE verifier for this flow. Absent when pkce was off. |
redirectUri | What the platform sent as the redirect URI. Replay it in the token exchange — providers compare the two exactly. |
data | Whatever you passed to start(). |
state | The flow’s state. Echo it in your redirect to the forms SDK page: the authorization URL always carries one, so the form rejects a completion that does not echo it. |
error, errorDescription | Present when the provider refused. |
Whatever the process returns becomes the HTTP response the browser follows, so ending with a 302
to the forms SDK callback page is what closes the popup and tells the form how it went.
Limits
Section titled “Limits”fcode.oauthneeds the platform. Likefcode.scheduleandfcode.storage, it is backed by the workspace’s meta token, so it is unavailable under a localfcode run. Exercise an OAuth flow on the platform, from an installed App.response_mode=fragmentand the implicit flow cannot work: a URL fragment never reaches a server, so there is nothing to dispatch on. Use the authorization code flow.- A provider that omits
statewhen refusing cannot be routed either; the platform sends the popup to the forms SDK page with an error instead.