Правки различные

This commit is contained in:
mi
2026-08-26 11:05:32 +03:00
parent c1e49fb15d
commit 728b9826a3
36 changed files with 722 additions and 1986 deletions
+13
View File
@@ -74,6 +74,19 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
try {
await SecureStore.setItemAsync(PENDING_CONSENTS_KEY, JSON.stringify(consents));
const result = await beginAuthorization();
if (getAccessToken()) {
setAuthStatus("authenticated");
await SecureStore.deleteItemAsync(PENDING_CONSENTS_KEY);
return true;
}
if (result.type === "dismiss" || result.type === "cancel") {
await new Promise((resolve) => setTimeout(resolve, 800));
if (getAccessToken()) {
setAuthStatus("authenticated");
await SecureStore.deleteItemAsync(PENDING_CONSENTS_KEY);
return true;
}
}
if (result.type !== "success" || typeof result.params.code !== "string" || typeof result.params.state !== "string") {
setAuthStatus("guest");
if (result.type !== "dismiss" && result.type !== "cancel") throw new Error("authorization_failed");
+68 -15
View File
@@ -1,8 +1,10 @@
import * as AuthSession from "expo-auth-session";
import * as Crypto from "expo-crypto";
import * as Linking from "expo-linking";
import * as SecureStore from "expo-secure-store";
import * as WebBrowser from "expo-web-browser";
import { env, oidcIssuer } from "./config";
import { AppState, Platform } from "react-native";
import { env, mobileHttpsRedirectUri, oidcIssuer } from "./config";
import { buildOidcDeviceMetadata } from "./oidc-device";
import { SingleFlight } from "./single-flight";
import type { TokenSet } from "./types";
@@ -34,10 +36,66 @@ const secureStore = {
del: (key: string) => SecureStore.deleteItemAsync(key),
};
type PkceState = {
verifier: string;
state: string;
nonce: string;
createdAt: number;
redirectUri: string;
};
const random = () => Crypto.randomUUID().replaceAll("-", "") + Crypto.randomUUID().replaceAll("-", "");
const redirectUri = AuthSession.makeRedirectUri({ scheme: "han-chat", path: "auth/callback" });
const nativeRedirectUri = AuthSession.makeRedirectUri({ scheme: "han-chat", path: "auth/callback" });
const tokenEndpoint = `${oidcIssuer}/protocol/openid-connect/token`;
function oauthRedirectUri() {
return Platform.OS === "android" ? mobileHttpsRedirectUri : nativeRedirectUri;
}
function isAuthCallbackUrl(url: string) {
return url.startsWith(nativeRedirectUri) || url.startsWith(mobileHttpsRedirectUri);
}
function paramsFromCallbackUrl(url: string) {
const callback = new URL(url);
return {
code: callback.searchParams.get("code"),
state: callback.searchParams.get("state"),
error: callback.searchParams.get("error"),
};
}
async function openAuthSession(authUrl: string) {
if (Platform.OS !== "android") {
return WebBrowser.openAuthSessionAsync(authUrl, nativeRedirectUri);
}
return new Promise<WebBrowser.WebBrowserAuthSessionResult>((resolve, reject) => {
let settled = false;
const finish = (result: WebBrowser.WebBrowserAuthSessionResult) => {
if (settled) return;
settled = true;
linkingSub.remove();
appSub.remove();
resolve(result);
};
const linkingSub = Linking.addEventListener("url", ({ url }) => {
if (isAuthCallbackUrl(url)) finish({ type: "success", url });
});
const appSub = AppState.addEventListener("change", (state) => {
if (state !== "active") return;
setTimeout(() => finish({ type: WebBrowser.WebBrowserResultType.DISMISS }), 1_500);
});
void WebBrowser.openBrowserAsync(authUrl, { createTask: false, showInRecents: true }).catch((error) => {
linkingSub.remove();
appSub.remove();
reject(error);
});
});
}
export function configureAuthFailure(callback: () => void) {
authFailure = callback;
}
@@ -78,12 +136,15 @@ export async function beginAuthorization() {
const verifier = random();
const state = random();
const nonce = random();
const redirectUri = oauthRedirectUri();
const digest = await Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, verifier, {
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() }));
await secureStore.set(PKCE_KEY, JSON.stringify({
verifier, state, nonce, createdAt: Date.now(), redirectUri,
} satisfies PkceState));
const url = `${oidcIssuer}/protocol/openid-connect/auth?${new URLSearchParams({
client_id: env.clientId,
redirect_uri: redirectUri,
@@ -95,17 +156,9 @@ export async function beginAuthorization() {
nonce,
...deviceMetadata,
})}`;
const result = await WebBrowser.openAuthSessionAsync(url, redirectUri);
const result = await openAuthSession(url);
if (result.type !== "success") return { type: result.type as "cancel" | "dismiss" };
const callback = new URL(result.url);
return {
type: "success" as const,
params: {
code: callback.searchParams.get("code"),
state: callback.searchParams.get("state"),
error: callback.searchParams.get("error"),
},
};
return { type: "success" as const, params: paramsFromCallbackUrl(result.url) };
}
export function completeAuthorization(code: string, state: string) {
@@ -117,7 +170,7 @@ async function completeAuthorizationOnce(code: string, state: string) {
const raw = await secureStore.get(PKCE_KEY);
await secureStore.del(PKCE_KEY);
if (!raw) throw new Error("pkce_state_missing");
const saved = JSON.parse(raw) as { verifier: string; state: string; nonce: string; createdAt: number };
const saved = JSON.parse(raw) as PkceState;
if (saved.state !== state || Date.now() - saved.createdAt > PKCE_TTL_MS) {
throw new Error("pkce_state_invalid");
}
@@ -127,7 +180,7 @@ async function completeAuthorizationOnce(code: string, state: string) {
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: env.clientId,
redirect_uri: redirectUri,
redirect_uri: saved.redirectUri || oauthRedirectUri(),
code,
code_verifier: saved.verifier,
}).toString(),
+2
View File
@@ -12,5 +12,7 @@ export const env = Object.freeze({
appEnv: process.env.EXPO_PUBLIC_APP_ENV ?? "development",
});
export const mobileHttpsRedirectUri = `${env.apiBaseUrl}/mobile/oidc/callback`;
export const oidcIssuer = `${env.authBaseUrl}/realms/${encodeURIComponent(env.realm)}`;
export const isProduction = env.appEnv === "production";
+4 -1
View File
@@ -20,6 +20,8 @@ vi.mock("expo-secure-store", () => ({
}));
vi.mock("expo-web-browser", () => ({
maybeCompleteAuthSession: vi.fn(),
openAuthSessionAsync: vi.fn(),
openBrowserAsync: vi.fn(),
}));
vi.mock("expo-document-picker", () => ({
getDocumentAsync: vi.fn(),
@@ -35,6 +37,7 @@ vi.mock("expo-file-system/legacy", () => ({
}));
vi.mock("expo-linking", () => ({
openURL: vi.fn(),
addEventListener: vi.fn(() => ({ remove: vi.fn() })),
}));
vi.mock("expo-sharing", () => ({
isAvailableAsync: vi.fn(),
@@ -42,7 +45,7 @@ vi.mock("expo-sharing", () => ({
}));
vi.mock("react-native", () => ({
Platform: { OS: "android", Version: "test", constants: {} },
AppState: { currentState: "active" },
AppState: { currentState: "active", addEventListener: vi.fn(() => ({ remove: vi.fn() })) },
}));
import { buildOidcDeviceMetadata } from "../../src/oidc-device";