Реализована интеграция с СМС провайдером
This commit is contained in:
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user