175 lines
5.7 KiB
TypeScript
175 lines
5.7 KiB
TypeScript
import axios from 'axios';
|
|
import crypto from 'crypto';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
|
|
type OlistV3Tokens = {
|
|
access_token: string;
|
|
refresh_token?: string;
|
|
expires_in?: number;
|
|
expires_at?: string;
|
|
};
|
|
|
|
type OlistV3AuthorizationState = {
|
|
state: string;
|
|
createdAt: string;
|
|
};
|
|
|
|
const OAUTH_BASE_URL = 'https://accounts.tiny.com.br/realms/tiny/protocol/openid-connect';
|
|
const TOKEN_URL = `${OAUTH_BASE_URL}/token`;
|
|
const AUTHORIZE_URL = `${OAUTH_BASE_URL}/auth`;
|
|
const DEFAULT_TOKEN_FILE = path.join(process.cwd(), 'data-runtime', 'olist_v3_tokens.json');
|
|
const DEFAULT_STATE_FILE = path.join(process.cwd(), 'data-runtime', 'olist_v3_oauth_state.json');
|
|
|
|
function requiredEnv(name: string) {
|
|
const value = process.env[name]?.trim();
|
|
if (!value) throw new Error(`Missing ${name}.`);
|
|
return value;
|
|
}
|
|
|
|
function getTokenFile() {
|
|
return process.env.OLIST_V3_TOKEN_FILE || DEFAULT_TOKEN_FILE;
|
|
}
|
|
|
|
function getStateFile() {
|
|
return process.env.OLIST_V3_OAUTH_STATE_FILE || DEFAULT_STATE_FILE;
|
|
}
|
|
|
|
function ensureParentDirectory(filePath: string) {
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
}
|
|
|
|
function writeJson(filePath: string, value: unknown) {
|
|
ensureParentDirectory(filePath);
|
|
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), { mode: 0o600 });
|
|
}
|
|
|
|
function readJson<T>(filePath: string): T | null {
|
|
if (!fs.existsSync(filePath)) return null;
|
|
return JSON.parse(fs.readFileSync(filePath, 'utf-8')) as T;
|
|
}
|
|
|
|
function getClientConfig() {
|
|
return {
|
|
clientId: requiredEnv('OLIST_V3_CLIENT_ID'),
|
|
clientSecret: requiredEnv('OLIST_V3_CLIENT_SECRET'),
|
|
redirectUri: requiredEnv('OLIST_V3_REDIRECT_URI')
|
|
};
|
|
}
|
|
|
|
function saveTokens(tokens: OlistV3Tokens) {
|
|
const expiresIn = Number(tokens.expires_in || 0);
|
|
const expiresAt = Number.isFinite(expiresIn) && expiresIn > 0
|
|
? new Date(Date.now() + Math.max(0, expiresIn - 60) * 1000).toISOString()
|
|
: undefined;
|
|
const previous = readJson<OlistV3Tokens>(getTokenFile());
|
|
|
|
writeJson(getTokenFile(), {
|
|
...previous,
|
|
...tokens,
|
|
refresh_token: tokens.refresh_token || previous?.refresh_token,
|
|
expires_at: expiresAt || previous?.expires_at
|
|
});
|
|
}
|
|
|
|
function tokenIsUsable(tokens: OlistV3Tokens) {
|
|
if (!tokens.access_token) return false;
|
|
if (!tokens.expires_at) return true;
|
|
return new Date(tokens.expires_at).getTime() > Date.now();
|
|
}
|
|
|
|
async function requestTokens(params: URLSearchParams) {
|
|
const response = await axios.post<OlistV3Tokens>(TOKEN_URL, params, {
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
timeout: 30000
|
|
});
|
|
if (!response.data?.access_token) {
|
|
throw new Error('Olist V3 OAuth did not return an access token.');
|
|
}
|
|
|
|
saveTokens(response.data);
|
|
return response.data;
|
|
}
|
|
|
|
export function isOlistV3Configured() {
|
|
return Boolean(
|
|
process.env.OLIST_V3_CLIENT_ID?.trim()
|
|
&& process.env.OLIST_V3_CLIENT_SECRET?.trim()
|
|
&& process.env.OLIST_V3_REDIRECT_URI?.trim()
|
|
);
|
|
}
|
|
|
|
export function getOlistV3AuthorizationUrl() {
|
|
const { clientId, redirectUri } = getClientConfig();
|
|
const state = crypto.randomBytes(32).toString('hex');
|
|
writeJson(getStateFile(), { state, createdAt: new Date().toISOString() } satisfies OlistV3AuthorizationState);
|
|
|
|
const params = new URLSearchParams({
|
|
client_id: clientId,
|
|
redirect_uri: redirectUri,
|
|
scope: 'openid',
|
|
response_type: 'code',
|
|
state
|
|
});
|
|
return `${AUTHORIZE_URL}?${params.toString()}`;
|
|
}
|
|
|
|
export async function exchangeOlistV3AuthorizationCode(code: string, state: string) {
|
|
const expected = readJson<OlistV3AuthorizationState>(getStateFile());
|
|
const expectedState = Buffer.from(expected?.state || '');
|
|
const receivedState = Buffer.from(state || '');
|
|
if (!expected?.state || expectedState.length !== receivedState.length || !crypto.timingSafeEqual(expectedState, receivedState)) {
|
|
throw new Error('Invalid OAuth state. Start the authorization flow again.');
|
|
}
|
|
|
|
const createdAt = new Date(expected.createdAt).getTime();
|
|
if (!Number.isFinite(createdAt) || Date.now() - createdAt > 10 * 60 * 1000) {
|
|
throw new Error('OAuth state expired. Start the authorization flow again.');
|
|
}
|
|
|
|
const { clientId, clientSecret, redirectUri } = getClientConfig();
|
|
const params = new URLSearchParams({
|
|
grant_type: 'authorization_code',
|
|
client_id: clientId,
|
|
client_secret: clientSecret,
|
|
redirect_uri: redirectUri,
|
|
code
|
|
});
|
|
|
|
const tokens = await requestTokens(params);
|
|
fs.rmSync(getStateFile(), { force: true });
|
|
return tokens;
|
|
}
|
|
|
|
export async function getOlistV3AccessToken(forceRefresh = false) {
|
|
const current = readJson<OlistV3Tokens>(getTokenFile());
|
|
if (current && !forceRefresh && tokenIsUsable(current)) {
|
|
return current.access_token;
|
|
}
|
|
|
|
if (!current?.refresh_token) {
|
|
throw new Error('Olist V3 is not authorized. Open /api/olist-v3/authorize first.');
|
|
}
|
|
|
|
const { clientId, clientSecret } = getClientConfig();
|
|
const params = new URLSearchParams({
|
|
grant_type: 'refresh_token',
|
|
client_id: clientId,
|
|
client_secret: clientSecret,
|
|
refresh_token: current.refresh_token
|
|
});
|
|
const tokens = await requestTokens(params);
|
|
return tokens.access_token;
|
|
}
|
|
|
|
export function getOlistV3AuthStatus() {
|
|
const tokens = readJson<OlistV3Tokens>(getTokenFile());
|
|
return {
|
|
configured: isOlistV3Configured(),
|
|
authorized: Boolean(tokens?.access_token || tokens?.refresh_token),
|
|
accessTokenUsable: Boolean(tokens && tokenIsUsable(tokens)),
|
|
tokenExpiresAt: tokens?.expires_at || null,
|
|
tokenFile: getTokenFile()
|
|
};
|
|
}
|