Реализована интеграция с СМС провайдером

This commit is contained in:
mi
2026-07-23 11:49:15 +03:00
parent cc0163eb94
commit b1ed714d5b
89 changed files with 5934 additions and 202 deletions
@@ -4,6 +4,7 @@ import * as SecureStore from "expo-secure-store";
import * as WebBrowser from "expo-web-browser";
import { Platform } from "react-native";
import { env, oidcIssuer } from "./config";
import { buildOidcDeviceMetadata } from "./oidc-device";
import { SingleFlight } from "./single-flight";
import type { TokenSet } from "./types";
@@ -84,6 +85,7 @@ export async function beginAuthorization() {
encoding: Crypto.CryptoEncoding.BASE64,
});
const challenge = digest.replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
const deviceMetadata = await buildOidcDeviceMetadata(secureStore);
await secureStore.set(PKCE_KEY, JSON.stringify({ verifier, state, nonce, createdAt: Date.now() }));
const url = `${oidcIssuer}/protocol/openid-connect/auth?${new URLSearchParams({
client_id: env.clientId,
@@ -94,6 +96,7 @@ export async function beginAuthorization() {
code_challenge_method: "S256",
state,
nonce,
...deviceMetadata,
})}`;
if (Platform.OS === "web" && typeof window !== "undefined") {
window.location.assign(url);
@@ -0,0 +1,94 @@
import Constants from "expo-constants";
import * as Crypto from "expo-crypto";
import { Platform } from "react-native";
const DEVICE_ID_KEY = "han.web-device-id";
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
type Store = {
get(key: string): Promise<string | null>;
set(key: string, value: string): Promise<void>;
};
export type OidcDeviceMetadata = Partial<Record<
| "han_device_id"
| "han_fingerprint"
| "han_platform"
| "han_os_name"
| "han_os_version"
| "han_app_version",
string
>>;
function safe(value: unknown, maxLength: number): string | undefined {
if (typeof value !== "string") return undefined;
const normalized = value.trim();
if (!normalized || normalized.length > maxLength || CONTROL_CHARACTERS.test(normalized)) {
return undefined;
}
return normalized;
}
type BrowserDetails = {
osName?: string;
osVersion?: string;
fingerprintSource?: string;
};
function browserDetails(): BrowserDetails {
if (typeof navigator === "undefined") return {};
const userAgent = navigator.userAgent;
const platform = safe(navigator.platform, 64);
const windows = userAgent.match(/Windows NT ([\d.]+)/);
const android = userAgent.match(/Android ([\d.]+)/);
const ios = userAgent.match(/(?:iPhone )?OS ([\d_]+)/);
const osName = windows ? "Windows" : android ? "Android" : ios ? "iOS" : platform;
const osVersion = windows?.[1] ?? android?.[1] ?? ios?.[1]?.replaceAll("_", ".");
return {
...(osName ? { osName } : {}),
...(osVersion ? { osVersion } : {}),
fingerprintSource: [
userAgent,
navigator.language,
platform,
Intl.DateTimeFormat().resolvedOptions().timeZone,
typeof screen === "undefined" ? "" : `${screen.width}x${screen.height}`,
].join("|"),
};
}
export async function buildOidcDeviceMetadata(store: Store): Promise<OidcDeviceMetadata> {
const platform = Platform.OS === "ios" || Platform.OS === "android" ? Platform.OS : "web";
let deviceId = safe(await store.get(DEVICE_ID_KEY), 256);
if (!deviceId) {
deviceId = Crypto.randomUUID();
await store.set(DEVICE_ID_KEY, deviceId);
}
const browser = platform === "web" ? browserDetails() : {};
const constants = Platform.constants as unknown as Record<string, unknown>;
const fingerprintSource = browser.fingerprintSource
?? [platform, constants.Brand, constants.Model, constants.osVersion].join("|");
const fingerprint = await Crypto.digestStringAsync(
Crypto.CryptoDigestAlgorithm.SHA256,
`${deviceId}|${fingerprintSource}`,
);
const osName = browser.osName
?? safe(constants.systemName, 64)
?? (platform === "ios" ? "iOS" : platform === "android" ? "Android" : undefined);
const osVersion = browser.osVersion
?? safe(String(constants.osVersion ?? Platform.Version ?? ""), 64);
const appVersion = safe(Constants.expoConfig?.version, 64);
const safeOsName = safe(osName, 64);
const safeOsVersion = safe(osVersion, 64);
return {
han_device_id: deviceId,
han_fingerprint: fingerprint,
han_platform: platform,
...(safeOsName ? { han_os_name: safeOsName } : {}),
...(safeOsVersion ? { han_os_version: safeOsVersion } : {}),
...(appVersion ? { han_app_version: appVersion } : {}),
};
}
@@ -1,4 +1,18 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
vi.mock("expo-constants", () => ({
default: { expoConfig: { version: "1.0.0" } },
}));
vi.mock("expo-crypto", () => ({
CryptoDigestAlgorithm: { SHA256: "SHA-256" },
randomUUID: vi.fn(() => "123e4567-e89b-42d3-a456-426614174000"),
digestStringAsync: vi.fn(async () => "stable-fingerprint"),
}));
vi.mock("react-native", () => ({
Platform: { OS: "web", Version: "test", constants: {} },
}));
import { buildOidcDeviceMetadata } from "../../src/oidc-device";
import { reconcileMessages } from "../../src/reconcile";
import { sessionMemory } from "../../src/session";
import { SingleFlight } from "../../src/single-flight";
@@ -95,3 +109,26 @@ describe("WebSocket authentication protocol", () => {
expect(protocol).not.toContain("=");
});
});
describe("OIDC device metadata", () => {
it("создаёт стабильный web UUID и передаёт доступные han_* поля", async () => {
const values = new Map<string, string>();
const store = {
get: async (key: string) => values.get(key) ?? null,
set: async (key: string, value: string) => {
values.set(key, value);
},
};
const first = await buildOidcDeviceMetadata(store);
const second = await buildOidcDeviceMetadata(store);
expect(first.han_device_id).toBe("123e4567-e89b-42d3-a456-426614174000");
expect(second.han_device_id).toBe(first.han_device_id);
expect(first).toMatchObject({
han_fingerprint: "stable-fingerprint",
han_platform: "web",
han_app_version: "1.0.0",
});
});
});