Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .sizelimit.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"limits": {
"total": 600000,
"total": 650000,
"totalGzip": 250000,
"css": 150000,
"cssGzip": 25000,
Expand Down
85 changes: 85 additions & 0 deletions example/api/jwt_auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@ async function fetchCompanyManagerToken() {

// Express route handler
async function getCompanyManagerToken(req, res) {
if (process.env.NODE_ENV === 'production') {
return res
.status(403)
.json({ error: 'This endpoint is not available in production mode' });
}
try {
const { accessToken, expiresIn } = await fetchCompanyManagerToken();

Expand All @@ -101,8 +106,88 @@ async function getCompanyManagerToken(req, res) {
}
}

const EMPLOYEE_SCOPES = 'all:write';

async function fetchEmployeeToken(employmentId) {
const { VITE_CLIENT_ID, VITE_CLIENT_SECRET, VITE_REMOTE_GATEWAY } =
process.env;

if (
!VITE_CLIENT_ID ||
(!VITE_CLIENT_SECRET && VITE_REMOTE_GATEWAY !== 'local') ||
!VITE_REMOTE_GATEWAY ||
!employmentId
) {
throw new Error(
'Missing VITE_CLIENT_ID, VITE_CLIENT_SECRET, or employmentId',
);
}

const gatewayUrl = buildGatewayURL();
const now = Math.floor(Date.now() / 1000);
const exp = now + 5 * 60;

const payload = {
iss: VITE_CLIENT_ID,
sub: `urn:remote-api:employee:employment:${employmentId}`,
aud: `${gatewayUrl}/auth`,
exp,
scope: EMPLOYEE_SCOPES,
iat: now,
};

const jwtToken = jwt.sign(payload, VITE_CLIENT_SECRET, {
algorithm: 'HS256',
});

const encodedCredentials = Buffer.from(
`${VITE_CLIENT_ID}:${VITE_CLIENT_SECRET}`,
).toString('base64');

const response = await fetch(`${gatewayUrl}/auth/oauth2/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${encodedCredentials}`,
},
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: jwtToken,
scope: EMPLOYEE_SCOPES,
}),
});

if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP ${response.status}: ${errorText}`);
}

const data = await response.json();
return { accessToken: data.access_token, expiresIn: data.expires_in };
}

async function getEmployeeToken(req, res) {
if (process.env.NODE_ENV === 'production') {
return res
.status(403)
.json({ error: 'This endpoint is not available in production mode' });
}
const { employmentId } = req.params;
try {
const { accessToken, expiresIn } = await fetchEmployeeToken(employmentId);
return res
.status(200)
.json({ access_token: accessToken, expires_in: expiresIn });
} catch (error) {
console.error('Error fetching employee token:', error);
return res.status(500).json({ error: error.message });
}
}

module.exports = {
getCompanyManagerToken,
generateJWTToken,
fetchCompanyManagerToken,
fetchEmployeeToken,
getEmployeeToken,
};
40 changes: 33 additions & 7 deletions example/api/proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,30 @@ const {
fetchClientCredentialsAccessToken,
fetchAccessToken,
} = require('./get_token.js');
const { fetchEmployeeToken } = require('./jwt_auth.js');
const { buildGatewayURL } = require('./utils.js');

/**
* Determines which token type to use based on HTTP method and path
* @param {string} method - HTTP method (GET, POST, PUT, PATCH, etc.)
* @param {string} path - The API path (e.g., '/v1/countries' or '/v2/countries?foo=bar')
* @returns {'client-credentials' | 'user-token'} The token type to use
* @returns {'client-credentials' | 'user-token' | 'employee-assertion'} The token type to use
*/
function getTokenType(method, path) {
const normalizedMethod = method.toUpperCase();
// Extract pathname without query parameters
const pathname = path.split('?')[0].toLowerCase();

// GET /v1/countries or /v2/countries
// GET /v1/countries or /v2/countries — these don't require a user identity;
// use client_credentials so the call works in CI where no user token is
// available. Local dev without a client secret can opt into a user token
// by setting VITE_REMOTE_GATEWAY=... and ensuring VITE_CLIENT_TOKEN works.
if (normalizedMethod === 'GET' && /^\/v[12]\/countries$/.test(pathname)) {
return 'client-credentials';
}

// GET /v1/countries/{country_code}/address_details or /v2/countries/{country_code}/address_details
// GET /v[12]/countries/{country_code}/address_details — public reference
// data; also use client credentials.
if (
normalizedMethod === 'GET' &&
/^\/v[12]\/countries\/[^/]+\/address_details$/.test(pathname)
Expand Down Expand Up @@ -50,6 +55,13 @@ function getTokenType(method, path) {
return 'client-credentials';
}

// /v1/employee/* endpoints need an employment-scoped assertion. The FE
// identifies which employment via the x-rf-employment-id header; the proxy
// mints the JWT-bearer token server-side so the FE never sees it.
if (/^\/v1\/employee\//.test(pathname)) {
return 'employee-assertion';
}

// All other requests use user token
return 'user-token';
}
Expand Down Expand Up @@ -94,11 +106,25 @@ async function createProxyRequest(path, method = 'GET', options = {}) {
// Add authentication if required
if (requiresAuth) {
const tokenType = getTokenType(method, path);
const { accessToken } =
tokenType === 'client-credentials'
? await fetchClientCredentialsAccessToken()
: await fetchAccessToken();
let accessToken;
if (tokenType === 'client-credentials') {
({ accessToken } = await fetchClientCredentialsAccessToken());
} else if (tokenType === 'employee-assertion') {
const employmentId = headers['x-rf-employment-id'];
if (!employmentId) {
throw Object.assign(
new Error('Missing x-rf-employment-id header for employee request'),
{
response: { status: 400, data: { error: 'employmentId required' } },
},
);
}
({ accessToken } = await fetchEmployeeToken(employmentId));
} else {
({ accessToken } = await fetchAccessToken());
}
requestConfig.headers.Authorization = `Bearer ${accessToken}`;
delete requestConfig.headers['x-rf-employment-id'];
}

return axios(requestConfig);
Expand Down
3 changes: 2 additions & 1 deletion example/api/routes.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
const { getToken } = require('./get_token.js');
const { getCompanyManagerToken } = require('./jwt_auth.js');
const { getCompanyManagerToken, getEmployeeToken } = require('./jwt_auth.js');
const { createProxyMiddleware } = require('./proxy.js');

function setupRoutes(app) {
// API routes
app.get('/api/fetch-refresh-token', getToken);
app.get('/api/fetch-company-manager', getCompanyManagerToken);
app.get('/api/fetch-employee-token/:employmentId', getEmployeeToken);

// Proxy all versioned API routes (v1, v2, etc.)
app.use(/^\/v\d+/, createProxyMiddleware());
Expand Down
16 changes: 16 additions & 0 deletions example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ import { ContractorOnboardingForm } from './ContractorOnboarding';
import { CreateCompanyForm } from './CreateCompany';
import { MagicLinkTest } from './MagicLinkTest';
import { JsonSchemaComparisonDemo } from './JsonSchemaComparisonDemo';
import { PayrollAdminOnboardingForm } from './PayrollAdminOnboarding';
import { PayrollEmployeeOnboardingForm } from './PayrollEmployeeOnboarding';
import ContractorOnboardingCode from './ContractorOnboarding?raw';
import CreateCompanyCode from './CreateCompany?raw';
import MagicLinkTestCode from './MagicLinkTest?raw';
Expand Down Expand Up @@ -175,6 +177,20 @@ const additionalDemos = [
component: JsonSchemaComparisonDemo,
sourceCode: JsonSchemaComparisonCode,
},
{
Comment thread
remotecom marked this conversation as resolved.
id: 'payroll-admin-onboarding',
title: 'GP Admin Onboarding',
description: 'Global Payroll admin onboarding flow',
component: PayrollAdminOnboardingForm,
sourceCode: '',
},
{
id: 'payroll-employee-onboarding',
title: 'GP Employee Onboarding',
description: 'Global Payroll employee self-onboarding flow',
component: PayrollEmployeeOnboardingForm,
sourceCode: '',
},
];

const demoStructure = [
Expand Down
Loading
Loading