refactor: overhaul auth system, routing, and navigation flows

This commit overhauls several core parts of the codebase:
- Replace legacy localStorage token storage with secure cookie-based authentication with automatic token refresh logic
- Rewrite checkToken and goLogin hooks to support redirect targets and proper async error handling
- Update all component navigation flows to use the updated auth checks and correct redirect behavior
- Refactor router configuration to support both client-side and server-side rendering via conditional history usage
- Remove unused speech recognition constant file and stale component registration for RecordingWaveBtn
- Add unit tests for navigation utilities and auth token handling
- Fix login post-success redirect logic and add login success event emissions
- Overhaul HTTP request handling to include automatic auth token injection and retry on unauthorized errors
This commit is contained in:
DEV_DSW
2026-06-04 11:07:30 +08:00
parent b71097155e
commit 354232b444
15 changed files with 712 additions and 91 deletions

2
components.d.ts vendored
View File

@@ -32,7 +32,6 @@ declare module 'vue' {
ModuleTitle: typeof import('./src/components/ModuleTitle/index.vue')['default']
Privacy: typeof import('./src/components/Privacy/index.vue')['default']
Qrcode: typeof import('./src/components/Qrcode/index.vue')['default']
RecordingWaveBtn: typeof import('./src/components/Speech/RecordingWaveBtn.vue')['default']
RefundPopup: typeof import('./src/components/RefundPopup/index.vue')['default']
ResponseIntro: typeof import('./src/components/ResponseIntro/index.vue')['default']
ResponseWrapper: typeof import('./src/components/ResponseWrapper/index.vue')['default']
@@ -89,7 +88,6 @@ declare global {
const ModuleTitle: typeof import('./src/components/ModuleTitle/index.vue')['default']
const Privacy: typeof import('./src/components/Privacy/index.vue')['default']
const Qrcode: typeof import('./src/components/Qrcode/index.vue')['default']
const RecordingWaveBtn: typeof import('./src/components/Speech/RecordingWaveBtn.vue')['default']
const RefundPopup: typeof import('./src/components/RefundPopup/index.vue')['default']
const ResponseIntro: typeof import('./src/components/ResponseIntro/index.vue')['default']
const ResponseWrapper: typeof import('./src/components/ResponseWrapper/index.vue')['default']

View File

@@ -1,4 +1,6 @@
import { request } from "../utils/request";
import { storeAuthTokens } from "@/constants/token";
import type { OAuthTokenResponse } from "@/constants/token";
import { request, requestRaw } from "../utils/request";
// 获取oauth token
export interface OauthTokenRequest {
@@ -28,18 +30,27 @@ export function buildFormUrlEncodedParams(
return params;
}
export function oauthToken(data: OauthTokenRequest) {
export async function oauthToken(data: OauthTokenRequest): Promise<OAuthTokenResponse> {
const params = buildFormUrlEncodedParams(data as Record<string, unknown>);
return request({
url: "/auth/oauth2/token",
method: "post",
data: params,
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: "Basic Y3VzdG9tOmN1c3RvbQ==",
const res = await requestRaw<OAuthTokenResponse>(
{
url: "/auth/oauth2/token",
method: "post",
data: params,
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: "Basic Y3VzdG9tOmN1c3RvbQ==",
},
},
{ skipAuth: true },
);
storeAuthTokens(res, {
requireRefreshToken: data.grant_type !== "refresh_token",
});
return res;
}
// 发送手机验证码
@@ -50,7 +61,7 @@ export function sendCode(mobile: string) {
return request({
url: `/admin/mobile/${encoded}`,
method: "get",
});
}, { skipAuth: true });
}
// 检测用户是否绑定手机号

View File

@@ -1,7 +0,0 @@
// App 端 yao-asdRealSpeech 使用的阿里云 DashScope 实时语音识别配置。
// 将 apikey 填成实际的 DashScope API Key 后App 端语音识别即可发起连接。
export const appSpeechRecognitionOptions = {
apikey: "sk-2cab1c221b4b47749119d33ab991360a",
language_hints: ["zh"],
saveAudioFile: false,
};

120
src/constants/token.test.ts Normal file
View File

@@ -0,0 +1,120 @@
import assert from "node:assert/strict";
import { afterEach, beforeEach, describe, it } from "node:test";
import {
clearAuthTokens,
getAccessToken,
getAccessTokenExpiresAt,
getRefreshToken,
isAccessTokenExpired,
storeAuthTokens,
} from "./token.ts";
type CookieEntry = {
value: string;
};
function installCookieDocument() {
const cookies = new Map<string, CookieEntry>();
const previous = Object.getOwnPropertyDescriptor(globalThis, "document");
Object.defineProperty(globalThis, "document", {
configurable: true,
value: {
get cookie() {
return Array.from(cookies.entries())
.map(([name, entry]) => `${name}=${entry.value}`)
.join("; ");
},
set cookie(rawCookie: string) {
const [nameValue, ...attributes] = rawCookie.split(";").map((item) => item.trim());
const [name, value] = nameValue.split("=");
const maxAge = attributes.find((item) => item.toLowerCase().startsWith("max-age="));
if (maxAge?.toLowerCase() === "max-age=0") {
cookies.delete(name);
return;
}
cookies.set(name, { value });
},
},
});
return () => {
if (previous) {
Object.defineProperty(globalThis, "document", previous);
return;
}
Reflect.deleteProperty(globalThis, "document");
};
}
describe("auth token cookies", () => {
let restoreDocument: () => void;
beforeEach(() => {
restoreDocument = installCookieDocument();
});
afterEach(() => {
clearAuthTokens();
restoreDocument();
});
it("stores oauth tokens in cookies with the access token expiry", () => {
const stored = storeAuthTokens(
{
access_token: "access.token",
refresh_token: "refresh.token",
expires_in: "120",
},
{ nowMs: 1_000, requireRefreshToken: true },
);
assert.equal(stored.accessToken, "access.token");
assert.equal(stored.refreshToken, "refresh.token");
assert.equal(stored.expiresAt, 121_000);
assert.equal(getAccessToken(), "access.token");
assert.equal(getRefreshToken(), "refresh.token");
assert.equal(getAccessTokenExpiresAt(), 121_000);
assert.equal(isAccessTokenExpired(120_999, 0), false);
assert.equal(isAccessTokenExpired(121_000, 0), true);
});
it("uses exp when the oauth response includes an absolute expiry", () => {
const stored = storeAuthTokens(
{
access_token: "access.from.exp",
refresh_token: "refresh.from.exp",
exp: "2025-09-09T06:06:22.155256377Z",
},
{ nowMs: 1_000, requireRefreshToken: true },
);
assert.equal(stored.expiresAt, Date.parse("2025-09-09T06:06:22.155Z"));
});
it("keeps the previous refresh token when a refresh response only returns access_token", () => {
storeAuthTokens(
{
access_token: "access.old",
refresh_token: "refresh.keep",
expires_in: "120",
},
{ nowMs: 1_000, requireRefreshToken: true },
);
storeAuthTokens(
{
access_token: "access.new",
expires_in: "240",
},
{ nowMs: 2_000 },
);
assert.equal(getAccessToken(), "access.new");
assert.equal(getRefreshToken(), "refresh.keep");
assert.equal(getAccessTokenExpiresAt(), 242_000);
});
});

View File

@@ -1,3 +1,272 @@
export function getAccessToken() {
return localStorage.getItem("accessToken");
export interface OAuthTokenResponse {
token_type?: string | null;
access_token?: string | null;
refresh_token?: string | null;
expires_in?: string | number | null;
exp?: string | number | null;
refresh_expires_in?: string | number | null;
}
export interface StoredAuthTokens {
accessToken: string;
refreshToken: string | null;
expiresAt: number | null;
}
interface StoreAuthTokenOptions {
nowMs?: number;
requireRefreshToken?: boolean;
}
interface CookieOptions {
expiresAt?: number;
maxAgeSeconds?: number;
}
const ACCESS_TOKEN_COOKIE = "access_token";
const REFRESH_TOKEN_COOKIE = "refresh_token";
const ACCESS_TOKEN_EXPIRES_AT_COOKIE = "access_token_expires_at";
const REFRESH_TOKEN_MAX_AGE_SECONDS = 30 * 24 * 60 * 60;
const DEFAULT_EXPIRY_SKEW_MS = 60_000;
const LEGACY_LOCAL_STORAGE_KEYS = ["accessToken", "token"];
function canUseDocument(): boolean {
return typeof document !== "undefined";
}
function canUseLocalStorage(): boolean {
return typeof localStorage !== "undefined";
}
function shouldUseSecureCookie(): boolean {
return typeof window !== "undefined" && window.location?.protocol === "https:";
}
function encodeCookiePart(value: string): string {
return encodeURIComponent(value);
}
function decodeCookiePart(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function getCookie(name: string): string | null {
if (!canUseDocument()) {
return null;
}
const encodedName = `${encodeCookiePart(name)}=`;
const cookies = document.cookie ? document.cookie.split(";") : [];
for (const cookie of cookies) {
const item = cookie.trim();
if (item.startsWith(encodedName)) {
return decodeCookiePart(item.slice(encodedName.length));
}
}
return null;
}
function setCookie(name: string, value: string, options: CookieOptions = {}): void {
if (!canUseDocument()) {
return;
}
const parts = [
`${encodeCookiePart(name)}=${encodeCookiePart(value)}`,
"Path=/",
"SameSite=Lax",
];
if (options.maxAgeSeconds !== undefined) {
parts.push(`Max-Age=${Math.max(0, Math.floor(options.maxAgeSeconds))}`);
}
if (options.expiresAt !== undefined) {
parts.push(`Expires=${new Date(options.expiresAt).toUTCString()}`);
}
if (shouldUseSecureCookie()) {
parts.push("Secure");
}
document.cookie = parts.join("; ");
}
function deleteCookie(name: string): void {
if (!canUseDocument()) {
return;
}
const parts = [
`${encodeCookiePart(name)}=`,
"Path=/",
"Max-Age=0",
"Expires=Thu, 01 Jan 1970 00:00:00 GMT",
"SameSite=Lax",
];
if (shouldUseSecureCookie()) {
parts.push("Secure");
}
document.cookie = parts.join("; ");
}
function parsePositiveSeconds(value: string | number | null | undefined): number | null {
if (value === null || value === undefined || value === "") {
return null;
}
const seconds = typeof value === "number" ? value : Number.parseInt(value, 10);
if (!Number.isFinite(seconds) || seconds <= 0) {
return null;
}
return seconds;
}
function normalizeIsoFraction(value: string): string {
return value.replace(/\.(\d{3})\d+(Z|[+-]\d{2}:?\d{2})$/, ".$1$2");
}
function parseAbsoluteTime(value: string | number | null | undefined): number | null {
if (value === null || value === undefined || value === "") {
return null;
}
if (typeof value === "number") {
if (!Number.isFinite(value) || value <= 0) {
return null;
}
return value < 1_000_000_000_000 ? value * 1000 : value;
}
const numericValue = Number(value);
if (Number.isFinite(numericValue) && numericValue > 0) {
return numericValue < 1_000_000_000_000 ? numericValue * 1000 : numericValue;
}
const parsed = Date.parse(normalizeIsoFraction(value));
return Number.isFinite(parsed) ? parsed : null;
}
export function resolveAccessTokenExpiresAt(
tokenResponse: OAuthTokenResponse,
nowMs = Date.now(),
): number | null {
const absoluteExpiresAt = parseAbsoluteTime(tokenResponse.exp);
if (absoluteExpiresAt !== null) {
return absoluteExpiresAt;
}
const expiresInSeconds = parsePositiveSeconds(tokenResponse.expires_in);
if (expiresInSeconds !== null) {
return nowMs + expiresInSeconds * 1000;
}
return null;
}
export function getAccessToken(): string | null {
return getCookie(ACCESS_TOKEN_COOKIE);
}
export function getRefreshToken(): string | null {
return getCookie(REFRESH_TOKEN_COOKIE);
}
export function getAccessTokenExpiresAt(): number | null {
const rawValue = getCookie(ACCESS_TOKEN_EXPIRES_AT_COOKIE);
if (!rawValue) {
return null;
}
const expiresAt = Number.parseInt(rawValue, 10);
return Number.isFinite(expiresAt) && expiresAt > 0 ? expiresAt : null;
}
export function isAccessTokenExpired(
nowMs = Date.now(),
skewMs = DEFAULT_EXPIRY_SKEW_MS,
): boolean {
const expiresAt = getAccessTokenExpiresAt();
if (expiresAt === null) {
return true;
}
return nowMs + skewMs >= expiresAt;
}
function clearLegacyLocalStorageTokens(): void {
if (!canUseLocalStorage()) {
return;
}
for (const key of LEGACY_LOCAL_STORAGE_KEYS) {
localStorage.removeItem(key);
}
}
export function clearAuthTokens(): void {
deleteCookie(ACCESS_TOKEN_COOKIE);
deleteCookie(REFRESH_TOKEN_COOKIE);
deleteCookie(ACCESS_TOKEN_EXPIRES_AT_COOKIE);
clearLegacyLocalStorageTokens();
}
export function storeAuthTokens(
tokenResponse: OAuthTokenResponse,
options: StoreAuthTokenOptions = {},
): StoredAuthTokens {
const accessToken = tokenResponse.access_token?.trim();
const nextRefreshToken = tokenResponse.refresh_token?.trim() || getRefreshToken();
if (!accessToken) {
clearAuthTokens();
throw new Error("Missing access_token");
}
if (options.requireRefreshToken && !nextRefreshToken) {
clearAuthTokens();
throw new Error("Missing refresh_token");
}
const nowMs = options.nowMs ?? Date.now();
const expiresAt = resolveAccessTokenExpiresAt(tokenResponse, nowMs);
if (expiresAt !== null) {
setCookie(ACCESS_TOKEN_COOKIE, accessToken, {
expiresAt,
maxAgeSeconds: Math.max(0, Math.floor((expiresAt - nowMs) / 1000)),
});
setCookie(ACCESS_TOKEN_EXPIRES_AT_COOKIE, String(expiresAt), {
expiresAt,
maxAgeSeconds: Math.max(0, Math.floor((expiresAt - nowMs) / 1000)),
});
} else {
setCookie(ACCESS_TOKEN_COOKIE, accessToken);
deleteCookie(ACCESS_TOKEN_EXPIRES_AT_COOKIE);
}
if (tokenResponse.refresh_token?.trim()) {
setCookie(REFRESH_TOKEN_COOKIE, tokenResponse.refresh_token.trim(), {
maxAgeSeconds:
parsePositiveSeconds(tokenResponse.refresh_expires_in) ??
REFRESH_TOKEN_MAX_AGE_SECONDS,
});
}
clearLegacyLocalStorageTokens();
return {
accessToken,
refreshToken: nextRefreshToken,
expiresAt,
};
}

View File

@@ -1,15 +1,22 @@
import type { RouteLocationRaw } from "vue-router";
import { ensureValidAuthToken } from "@/utils/request";
import { goLogin } from "./useNavigator";
// 检测token
export const checkToken = (): Promise<void> => {
const token = localStorage.getItem("token");
function stopCurrentAction(): Promise<void> {
return new Promise(() => {});
}
return new Promise((resolve) => {
export const checkToken = async (
redirect?: RouteLocationRaw,
): Promise<void> => {
try {
const token = await ensureValidAuthToken();
if (!token) {
goLogin();
return;
goLogin({ redirect });
return stopCurrentAction();
}
resolve();
});
} catch {
goLogin({ redirect });
return stopCurrentAction();
}
};

View File

@@ -0,0 +1,12 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { goLogin } from "./useNavigator.ts";
describe("useNavigator", () => {
it("can navigate to login outside a component setup context", async () => {
const resolved = await goLogin({ redirect: { name: "quick" } });
assert.equal(resolved.name, "login");
assert.equal(resolved.query.redirect, "/quick");
});
});

View File

@@ -1,22 +1,52 @@
import { useRouter } from "vue-router";
import type { RouteLocationRaw } from "vue-router";
import { router } from "../router/index.ts";
interface LoginNavigateOptions {
redirect?: RouteLocationRaw;
}
function navigate(location: RouteLocationRaw, replace = false) {
if (typeof window === "undefined") {
return Promise.resolve(router.resolve(location));
}
return replace ? router.replace(location) : router.push(location);
}
function resolveRedirect(redirect?: RouteLocationRaw): string | null {
const target = redirect ?? router.currentRoute.value.fullPath;
const resolved =
typeof target === "string" ? target : router.resolve(target).fullPath;
if (
!resolved ||
!resolved.startsWith("/") ||
resolved.startsWith("//") ||
resolved.startsWith("/login")
) {
return null;
}
return resolved;
}
// 跳转首页
export const goHome = () => {
const router = useRouter();
router.push({ name: "home" });
return navigate({ name: "home" });
};
// 跳转登录页
export const goLogin = () => {
const router = useRouter();
export const goLogin = (options: LoginNavigateOptions = {}) => {
const redirect = resolveRedirect(options.redirect);
const location = redirect
? { name: "login", query: { redirect } }
: { name: "login" };
router.push({ name: "login" });
if (router.currentRoute.value.name === "login") {
return navigate(location, true);
}
return navigate(location);
};
// 返回上一页
export const goBack = () => {
const router = useRouter();
router.back();
return router.back();
};

View File

@@ -154,6 +154,7 @@ import { IdUtils } from "@/utils/IdUtils";
import { resolveChatSocketUrl } from "@/utils/socketUrl";
import { appendLongTextChunk, createLongTextData } from "@/constants/longTextCard";
import { checkToken } from "@/hooks/useGoLogin";
import { ensureValidAuthToken } from "@/utils/request";
import { useAppStore } from "@/store";
import { getAccessToken } from "@/constants/token";
import Welcome from "../Welcome/index.vue";
@@ -470,9 +471,9 @@ const addNoticeListener = () => {
};
// token存在初始化数据
const initHandler = () => {
const initHandler = async () => {
console.log("initHandler");
const token = getAccessToken();
const token = await ensureValidAuthToken().catch(() => null);
if (!token) return;
loadRecentConversation();
@@ -546,8 +547,12 @@ const initWebSocket = async () => {
}
// 使用配置的WebSocket服务器地址
// const token = getAccessToken();
const token = import.meta.env.VITE_TOKEN;
const token = getAccessToken();
if (!token) {
isInitializing = false;
pendingInitPromise = null;
return false;
}
const resolvedWsUrl = resolveChatSocketUrl(appStore.serverConfig.wssUrl);
const wsUrl = `${resolvedWsUrl}?access_token=${token}`;

View File

@@ -56,7 +56,7 @@ const sendReply = (item) => {
// 快速预定
if (item.type === Command.quickBooking) {
checkToken().then(() => {
checkToken({ name: "quick" }).then(() => {
router.push({ name: "quick" });
});
return;
@@ -64,7 +64,7 @@ const sendReply = (item) => {
// 我的订单
if (item.type === Command.myOrder) {
checkToken().then(() => {
checkToken({ name: "orderList" }).then(() => {
router.push({ name: "orderList" });
});
return;

View File

@@ -101,7 +101,7 @@ const handleClick = (item) => {
close();
if (item.path) {
checkToken().then(() => {
checkToken({ path: item.path }).then(() => {
router.push({ path: item.path });
});

View File

@@ -59,13 +59,15 @@ const router = useRouter()
// 去下单
const placeOrderHandle = (item) => {
checkToken().then(() => {
router.push({
name: 'goods',
query: {
commodityId: item.commodityId,
}
})
const target = {
name: 'goods',
query: {
commodityId: item.commodityId,
}
};
checkToken(target).then(() => {
router.push(target)
});
};
</script>

View File

@@ -77,17 +77,40 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { useRoute, useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import { showToast } from "vant";
import { oauthToken, sendCode } from "@/api/login";
import { NOTICE_EVENT_LOGIN_SUCCESS } from "@/constants/constant";
import { COUNTRY_CALLING_CODES, findCountryCallingCode } from "@/constants/countryCallingCodes";
import { getCurrentLocale, setLocale } from "@/i18n";
import { emitter } from "@/utils/events";
import { Globe } from '@lucide/vue'
const router = useRouter();
const route = useRoute();
const { t } = useI18n();
function resolveLoginRedirect(): string | null {
const redirect = route.query.redirect;
const value = Array.isArray(redirect) ? redirect[0] : redirect;
if (!value || !value.startsWith("/") || value.startsWith("//")) {
return null;
}
if (value.startsWith("/login")) {
return null;
}
return value;
}
function navigateAfterLogin() {
const redirect = resolveLoginRedirect();
return router.replace(redirect ?? { name: "home" });
}
const googleButtonEl = ref<HTMLElement | null>(null);
const googleInited = ref(false);
const googleButtonRendered = ref(false);
@@ -231,7 +254,8 @@ async function handlePhoneLogin() {
code: codeValue,
grant_type: "mobile",
});
router.go(-1);
emitter.emit(NOTICE_EVENT_LOGIN_SUCCESS);
await navigateAfterLogin();
} catch (e: unknown) {
console.error(e);
showToast(t("common.errors.network"));
@@ -303,21 +327,14 @@ onMounted(() => {
"237078935964-e2pioe5hlfbcd4gaam4m9sn859iihdb9.apps.googleusercontent.com",
ux_mode: "popup",
callback: (response: any) => {
// 登录成功后,谷歌会直接把凭证传进这个函数
console.log("谷歌登录成功,收到凭证:", response);
// 这个 credential 就是 JWT (ID Token)
const idToken = response.credential;
if (idToken) {
// 【后续业务】:你可以将这串 idToken 通过 axios 发送给你自己的后端接口
// 或者是直接利用第三方平台(如 Firebase / Supabase完成纯前端鉴权
console.log("拿到的 ID Token 是:", idToken);
oauthToken({ openIdCode: [idToken], grant_type: 'google' })
.then((res) => {
console.log("获取到的 oauth token:", res)
router.go(-1)
.then(() => {
emitter.emit(NOTICE_EVENT_LOGIN_SUCCESS);
return navigateAfterLogin()
})
.catch((e) => {
console.error(e)

View File

@@ -1,4 +1,4 @@
import { createRouter, createWebHistory } from "vue-router";
import { createMemoryHistory, createRouter, createWebHistory } from "vue-router";
import type { RouteRecordRaw } from "vue-router";
export const routes = [
@@ -54,8 +54,19 @@ export const routes = [
},
] satisfies RouteRecordRaw[];
function resolveRouterBase(): string {
return import.meta.env?.BASE_URL ?? "/";
}
function createRouterHistory() {
const base = resolveRouterBase();
return typeof window === "undefined"
? createMemoryHistory(base)
: createWebHistory(base);
}
export const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
history: createRouterHistory(),
routes,
scrollBehavior() {
return { top: 0 };

View File

@@ -1,5 +1,13 @@
import axios from "axios";
import type { AxiosError, AxiosRequestConfig } from "axios";
import {
clearAuthTokens,
getAccessToken,
getRefreshToken,
isAccessTokenExpired,
storeAuthTokens,
} from "@/constants/token";
import type { OAuthTokenResponse } from "@/constants/token";
import { readStoredLocale } from "@/i18n/storage";
import { defaultLocale } from "@/i18n/types";
import type {
@@ -33,14 +41,20 @@ const http = axios.create({
timeout: resolveTimeout(),
});
const OAUTH_TOKEN_URL = "/auth/oauth2/token";
const OAUTH_BASIC_AUTHORIZATION = "Basic Y3VzdG9tOmN1c3RvbQ==";
const AUTH_REFRESH_ERROR_STATUSES = new Set([400, 401, 403]);
let context: RequestContext = {
token: null,
clientId: import.meta.env.VITE_CLIENT_ID,
token: getAccessToken(),
clientId: import.meta.env.VITE_CLIENT_ID ?? null,
latitude: null,
longitude: null,
language: null,
};
let refreshAccessTokenPromise: Promise<string> | null = null;
export function setAuthToken(token: string | null): void {
context = { ...context, token };
}
@@ -96,11 +110,9 @@ function resolveRequestLanguage(): string | null {
function buildContextHeaders(options?: RequestOptions): Record<string, string> {
const headers: Record<string, string> = {};
// 临时token后续需要从登录获取token
setAuthToken(import.meta.env.VITE_TOKEN);
if (!options?.skipAuth && context.token) {
headers.Authorization = `Bearer ${context.token}`;
const token = context.token ?? getAccessToken();
if (!options?.skipAuth && token) {
headers.Authorization = `Bearer ${token}`;
}
if (context.clientId) {
@@ -123,6 +135,127 @@ function buildContextHeaders(options?: RequestOptions): Record<string, string> {
return headers;
}
function buildRefreshTokenParams(refreshToken: string): URLSearchParams {
const params = new URLSearchParams();
params.append("grant_type", "refresh_token");
params.append("refresh_token", refreshToken);
return params;
}
async function refreshAccessToken(): Promise<string> {
if (refreshAccessTokenPromise) {
return refreshAccessTokenPromise;
}
refreshAccessTokenPromise = (async () => {
const refreshToken = getRefreshToken();
if (!refreshToken) {
clearAuthTokens();
setAuthToken(null);
throw new RequestError({
kind: "http",
httpStatus: 401,
message: "Missing refresh token",
});
}
try {
const res = await http.request<OAuthTokenResponse>({
url: OAUTH_TOKEN_URL,
method: "post",
data: buildRefreshTokenParams(refreshToken),
headers: {
...buildContextHeaders({ skipAuth: true }),
"Content-Type": "application/x-www-form-urlencoded",
Authorization: OAUTH_BASIC_AUTHORIZATION,
},
});
const tokens = storeAuthTokens(res.data);
setAuthToken(tokens.accessToken);
return tokens.accessToken;
} catch (e: unknown) {
if (
axios.isAxiosError(e) &&
e.response &&
AUTH_REFRESH_ERROR_STATUSES.has(e.response.status)
) {
clearAuthTokens();
setAuthToken(null);
}
throw e;
}
})().finally(() => {
refreshAccessTokenPromise = null;
});
return refreshAccessTokenPromise;
}
async function prepareAuthToken(options: RequestOptions): Promise<void> {
if (options.skipAuth) {
return;
}
const accessToken = getAccessToken();
if (!accessToken) {
setAuthToken(null);
if (getRefreshToken()) {
await refreshAccessToken();
}
return;
}
setAuthToken(accessToken);
if (isAccessTokenExpired()) {
await refreshAccessToken();
}
}
function shouldRetryUnauthorized(
error: unknown,
options: RequestOptions,
): boolean {
return (
!options.skipAuth &&
axios.isAxiosError(error) &&
error.response?.status === 401 &&
Boolean(getRefreshToken())
);
}
async function sendHttpRequest<T>(
config: AxiosRequestConfig,
options: RequestOptions,
allowAuthRetry = true,
) {
try {
await prepareAuthToken(options);
const ctxHeaders = buildContextHeaders(options);
const mergedHeaders: Record<string, string> = {
...ctxHeaders,
...((config.headers ?? {}) as Record<string, string>),
...(options.headers ?? {}),
};
return await http.request<T>({
...config,
signal: options.signal ?? config.signal,
headers: mergedHeaders,
});
} catch (e: unknown) {
if (allowAuthRetry && shouldRetryUnauthorized(e, options)) {
await refreshAccessToken();
return sendHttpRequest<T>(config, options, false);
}
throw e;
}
}
export async function ensureValidAuthToken(): Promise<string | null> {
await prepareAuthToken({});
return context.token ?? getAccessToken();
}
function isApiResponse(value: unknown): value is ApiResponse<unknown> {
if (!value || typeof value !== "object") {
return false;
@@ -154,19 +287,7 @@ export async function request<T>(
options: RequestOptions = {},
): Promise<ApiResponse<T>> {
try {
const ctxHeaders = buildContextHeaders(options);
const mergedHeaders: Record<string, string> = {
...ctxHeaders,
...((config.headers ?? {}) as Record<string, string>),
...(options.headers ?? {}),
};
const res = await http.request<ApiResponse<T>>({
...config,
signal: options.signal ?? config.signal,
headers: mergedHeaders,
});
const res = await sendHttpRequest<ApiResponse<T>>(config, options);
const body = res.data as unknown;
if (!isApiResponse(body)) {
throw new RequestError({
@@ -204,6 +325,31 @@ export async function request<T>(
}
}
export async function requestRaw<T>(
config: AxiosRequestConfig,
options: RequestOptions = {},
): Promise<T> {
try {
const res = await sendHttpRequest<T>(config, options);
return res.data;
} catch (e: unknown) {
if (e instanceof RequestError) {
throw e;
}
if (axios.isAxiosError(e)) {
throw normalizeAxiosError(e);
}
const err = e instanceof Error ? e : new Error(String(e));
throw new RequestError({
kind: "unknown",
message: err.message || "Unknown Error",
response: err,
});
}
}
export async function requestData<T>(
config: AxiosRequestConfig,
options: RequestOptions = {},