Правки различные
This commit is contained in:
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user