From f7099fb68fcf603bf7b687810bc35db4715f2c77 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Wed, 4 Nov 2026 02:12:27 +0800 Subject: [PATCH] feat(shell): sign in through the system auth session when a provider refuses the WebView Google answers an embedded WebView with disallowed_useragent, so the shell has been telling those users the sign-in method is not supported. It now opens the server's sign-in page in the OS auth session and brings the session back through the server's one-time-token hand-off, bound to a PKCE verifier kept in memory. The token is redeemed from inside the WebView, on the server's origin only, so the cookie lands in the WebView's store and the shell keeps nothing. A server that does not register ai.objectos.mobile keeps the notice. Co-Authored-By: Claude Opus 5.5 --- .changeset/148-system-sign-in.md | 15 ++ ROADMAP.md | 2 +- __tests__/components/ConsoleWebView.test.tsx | 131 +++++++++- __tests__/lib/shell/incoming-link.test.ts | 7 + __tests__/lib/shell/system-sign-in.test.ts | 245 +++++++++++++++++++ app.config.ts | 10 +- app/+native-intent.tsx | 6 +- components/shell/ConsoleWebView.tsx | 61 ++++- docs/DEPLOYMENT.md | 13 + docs/DEVICE-TESTING.md | 1 + docs/HYBRID-SHELL.md | 54 +++- lib/shell/incoming-link.ts | 7 + lib/shell/sign-in-excursion.ts | 3 +- lib/shell/system-sign-in-session.ts | 26 ++ lib/shell/system-sign-in.ts | 243 ++++++++++++++++++ package.json | 1 + pnpm-lock.yaml | 12 + 17 files changed, 813 insertions(+), 24 deletions(-) create mode 100644 .changeset/148-system-sign-in.md create mode 100644 __tests__/lib/shell/system-sign-in.test.ts create mode 100644 lib/shell/system-sign-in-session.ts create mode 100644 lib/shell/system-sign-in.ts diff --git a/.changeset/148-system-sign-in.md b/.changeset/148-system-sign-in.md new file mode 100644 index 0000000..65daa57 --- /dev/null +++ b/.changeset/148-system-sign-in.md @@ -0,0 +1,15 @@ +--- +"mobile": minor +--- + +Clause-②: no +Metadata-protocol: none + +Sign in with providers that refuse embedded WebViews, such as Google. When the +WebView is about to reach one, the app opens the server's sign-in page in the +system auth session (`ASWebAuthenticationSession`, Custom Tabs) and brings the +session back through the server's one-time-token hand-off, bound to a PKCE +verifier the app keeps in memory. A server that does not register the app's +`ai.objectos.mobile` scheme in `trustedAppSchemes` keeps the "not supported" +notice. The new scheme and `expo-crypto` are native changes, so this needs a new +binary. diff --git a/ROADMAP.md b/ROADMAP.md index b13d4dd..90b87c7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -40,7 +40,7 @@ - Biometric app lock (reuses `lib/biometric-auth.ts`, `lib/app-lock.ts`) - Bridge: `scan` (barcode / QR), `pickImage`, `openFile` / file download, `share` -- External IdP sign-in through `ASWebAuthenticationSession` / Custom Tabs +- External IdP sign-in through `ASWebAuthenticationSession` / Custom Tabs (mobile#148) ## Phase 5 — Release diff --git a/__tests__/components/ConsoleWebView.test.tsx b/__tests__/components/ConsoleWebView.test.tsx index 4c96e0e..1e03274 100644 --- a/__tests__/components/ConsoleWebView.test.tsx +++ b/__tests__/components/ConsoleWebView.test.tsx @@ -71,6 +71,11 @@ jest.mock("expo-notifications", () => ({ }), })); +let mockSystemSignIn: jest.Mock = jest.fn(); +jest.mock("~/lib/shell/system-sign-in-session", () => ({ + openSystemSignIn: (serverUrl: string) => mockSystemSignIn(serverUrl), +})); + const SERVER = "https://acme.example.com"; function renderConsole(overrides: Partial = {}) { @@ -1361,14 +1366,124 @@ describe("ConsoleWebView — sign-in excursion", () => { expect(mockWebView.props.source).toEqual({ uri: `${SERVER}/_console/login` }); }); - it("says the sign-in method is not supported yet for a provider that refuses WebViews", async () => { - const { props } = await onSignInPage(); - expect(decide("https://accounts.google.com/o/oauth2/v2/auth?client_id=x")).toBe(false); - expect(props.onOpenExternal).not.toHaveBeenCalled(); - expect(screen.getByTestId("shell-excursion-notice-unsupported")).toHaveTextContent( - "This sign-in method isn't supported in the app yet. Choose another way to sign in." - ); - expect(mockWebView.props.source).toEqual({ uri: `${SERVER}/_console/login` }); + describe("a provider that refuses WebViews", () => { + const GOOGLE = "https://accounts.google.com/o/oauth2/v2/auth?client_id=x"; + const SIGNED_IN = { kind: "signed-in", token: "T1", codeVerifier: "V1" }; + + /** The auth session stays open until the test settles it. */ + function holdSession() { + let settle: (outcome: unknown) => void = () => {}; + mockSystemSignIn = jest.fn( + () => + new Promise((resolve) => { + settle = resolve; + }) + ); + return { settle: async (outcome: unknown) => act(async () => settle(outcome)) }; + } + + /** The WebView the refusal re-created, back on the sign-in page and ready. */ + async function signInPageAgain() { + loadStart(`${SERVER}/_console/login`); + loadEndAt(`${SERVER}/_console/login`); + await consoleReady(); + } + + const redeemed = () => + mockWebView.injectJavaScript.mock.calls.filter(([script]) => + String(script).includes("one-time-token/verify") + ); + + it("opens the auth session on this server, and shows no notice while it is open", async () => { + holdSession(); + const { props } = await onSignInPage(); + expect(decide(GOOGLE)).toBe(false); + expect(mockSystemSignIn).toHaveBeenCalledWith(SERVER); + expect(props.onOpenExternal).not.toHaveBeenCalled(); + expect(screen.queryByTestId("shell-excursion-notice-unsupported")).toBeNull(); + expect(mockWebView.props.source).toEqual({ uri: `${SERVER}/_console/login` }); + }); + + it("starts it for a provider reached during an excursion too", async () => { + holdSession(); + await inExcursion(); + expect(decide(GOOGLE)).toBe(false); + expect(mockSystemSignIn).toHaveBeenCalledTimes(1); + expect(screen.queryByTestId("shell-excursion-bar")).toBeNull(); + }); + + it("opens one session at a time", async () => { + holdSession(); + await onSignInPage(); + decide(GOOGLE); + await signInPageAgain(); + decide(GOOGLE); + expect(mockSystemSignIn).toHaveBeenCalledTimes(1); + }); + + it("redeems in the WebView once the Console is ready, then boots behind the cover", async () => { + const session = holdSession(); + await onSignInPage(); + decide(GOOGLE); + // The token came back before the re-created page was ready: it waits. + await session.settle(SIGNED_IN); + expect(redeemed()).toHaveLength(0); + await signInPageAgain(); + expect(redeemed()).toHaveLength(1); + expect(redeemed()[0][0]).toContain(JSON.stringify(JSON.stringify({ token: "T1.V1" }))); + expect(screen.getByText("Loading…")).toBeTruthy(); + }); + + it("redeems at once when the Console is already ready, and only once", async () => { + const session = holdSession(); + await onSignInPage(); + decide(GOOGLE); + await signInPageAgain(); + await session.settle(SIGNED_IN); + expect(redeemed()).toHaveLength(1); + await signInPageAgain(); + expect(redeemed()).toHaveLength(1); + }); + + it("drops the token when the user switched connection meanwhile", async () => { + const session = holdSession(); + const { props, view } = await onSignInPage(); + decide(GOOGLE); + view.rerender( + + ); + await session.settle(SIGNED_IN); + loadStart("https://erp.example.com/_console/home"); + act(() => + mockWebView.props.onLoadEnd({ + nativeEvent: { url: "https://erp.example.com/_console/home", loading: false }, + }) + ); + await consoleReady(); + expect(redeemed()).toHaveLength(0); + }); + + it.each(["not-offered", "failed"])("says it is not supported on %s", async (kind) => { + const session = holdSession(); + await onSignInPage(); + decide(GOOGLE); + await session.settle({ kind }); + expect(screen.getByTestId("shell-excursion-notice-unsupported")).toHaveTextContent( + "This sign-in method isn't supported in the app yet. Choose another way to sign in." + ); + expect(redeemed()).toHaveLength(0); + }); + + it("says nothing when the user closed the session, and can open another", async () => { + const session = holdSession(); + await onSignInPage(); + decide(GOOGLE); + await session.settle({ kind: "cancelled" }); + expect(screen.queryByTestId("shell-excursion-notice-unsupported")).toBeNull(); + await signInPageAgain(); + decide(GOOGLE); + expect(mockSystemSignIn).toHaveBeenCalledTimes(2); + }); }); it("ends when the user switches to another connection", async () => { diff --git a/__tests__/lib/shell/incoming-link.test.ts b/__tests__/lib/shell/incoming-link.test.ts index c12ec4c..470b421 100644 --- a/__tests__/lib/shell/incoming-link.test.ts +++ b/__tests__/lib/shell/incoming-link.test.ts @@ -193,6 +193,13 @@ describe("consoleScreenHref", () => { }); describe("redirectIncomingPath", () => { + it("sends the sign-in hand-off's callback nowhere, so its token never becomes a path", () => { + expect(redirectIncomingPath("ai.objectos.mobile://auth/callback?token=abc")).toBe(""); + expect(redirectIncomingPath("ai.objectos.mobile://auth/callback")).toBe(""); + // Any other link on that scheme is an unknown link, as on the short scheme. + expect(redirectIncomingPath("ai.objectos.mobile://other")).toBe("/"); + }); + it("maps app links onto the Console screen, with a nonce", () => { expect(redirectIncomingPath("objectos://console/apps/crm", () => 42)).toBe( "/console?route=%2Fapps%2Fcrm&n=42" diff --git a/__tests__/lib/shell/system-sign-in.test.ts b/__tests__/lib/shell/system-sign-in.test.ts new file mode 100644 index 0000000..639f032 --- /dev/null +++ b/__tests__/lib/shell/system-sign-in.test.ts @@ -0,0 +1,245 @@ +import { createHash } from "node:crypto"; +import { + base64Url, + buildRedeemScript, + challengeFromBase64Digest, + HANDOFF_APP_SCHEME, + HANDOFF_CALLBACK_URL, + HANDOFF_FAILED_ERROR, + isHandoffCallback, + probeHandoffOffered, + runSystemSignIn, + signInEntryUrl, + tokenFromCallback, + type SystemSignInDeps, +} from "~/lib/shell/system-sign-in"; +import appConfig from "../../../app.config"; + +const SERVER = "https://acme.example.com"; +const CHALLENGE = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; + +const sha256Base64 = async (value: string) => + createHash("sha256").update(value, "ascii").digest("base64"); + +describe("PKCE", () => { + it("computes RFC 7636 appendix B's S256 challenge", async () => { + const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + expect(challengeFromBase64Digest(await sha256Base64(verifier))).toBe(CHALLENGE); + }); + + it("encodes RFC 7636 appendix B's verifier bytes", () => { + const bytes = Uint8Array.from([ + 116, 24, 223, 180, 151, 153, 224, 37, 79, 250, 96, 125, 216, 173, 187, 186, 22, 212, 37, 77, + 105, 214, 191, 240, 91, 88, 5, 88, 83, 132, 141, 121, + ]); + expect(base64Url(bytes)).toBe("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"); + }); + + it("leaves no padding on lengths that are not a multiple of three", () => { + expect(base64Url(Uint8Array.from([255]))).toBe("_w"); + expect(base64Url(Uint8Array.from([255, 255]))).toBe("__8"); + }); +}); + +describe("the scheme", () => { + it("is one the server's TrustedAppSchemeSchema accepts: lowercase reverse-domain, no suffix", () => { + expect(HANDOFF_APP_SCHEME).toMatch(/^[a-z][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)+$/); + expect(HANDOFF_CALLBACK_URL).toBe("ai.objectos.mobile://auth/callback"); + }); + + it("is registered with the OS, next to the app-link scheme", () => { + const { scheme } = appConfig({ config: {} } as never); + expect(scheme).toEqual(["objectos", HANDOFF_APP_SCHEME]); + }); +}); + +describe("signInEntryUrl", () => { + function redirectOf(url: string | null): string | null { + return new URL(url!).searchParams.get("redirect"); + } + + it("opens the Console's sign-in page, going to the hand-off afterwards", () => { + const entry = signInEntryUrl(SERVER, CHALLENGE); + expect(entry!.startsWith(`${SERVER}/_console/login?redirect=`)).toBe(true); + expect(redirectOf(entry)).toBe( + "/api/v1/auth/one-time-token/handoff?redirect_uri=ai.objectos.mobile%3A%2F%2Fauth%2Fcallback" + + `&code_challenge=${CHALLENGE}&code_challenge_method=S256` + ); + }); + + it("keeps a server's base path", () => { + const entry = signInEntryUrl(`${SERVER}/erp`, CHALLENGE); + expect(entry!.startsWith(`${SERVER}/erp/_console/login?`)).toBe(true); + expect(redirectOf(entry)!.startsWith("/erp/api/v1/auth/one-time-token/handoff?")).toBe(true); + }); + + it("carries only the challenge, never a verifier", () => { + expect(new URL(signInEntryUrl(SERVER, CHALLENGE)!).search).not.toContain("verifier"); + }); +}); + +describe("tokenFromCallback", () => { + it("reads the token of the one shape the hand-off answers", () => { + expect(tokenFromCallback(`${HANDOFF_CALLBACK_URL}?token=aZ09-_`)).toBe("aZ09-_"); + }); + + it.each([ + ["another scheme", "objectos://auth/callback?token=abc"], + ["another host", "ai.objectos.mobile://open/callback?token=abc"], + ["another path", "ai.objectos.mobile://auth/other?token=abc"], + ["no token", HANDOFF_CALLBACK_URL], + ["an empty token", `${HANDOFF_CALLBACK_URL}?token=`], + ["a repeated token", `${HANDOFF_CALLBACK_URL}?token=a&token=b`], + ["an extra parameter", `${HANDOFF_CALLBACK_URL}?token=a&next=https://evil.example`], + ["a value that could leave the JSON string", `${HANDOFF_CALLBACK_URL}?token=a%22`], + ["a dot, which would forge the verifier separator", `${HANDOFF_CALLBACK_URL}?token=a.b`], + ["an https look-alike", "https://auth/callback?token=abc"], + ])("refuses %s", (_name, link) => { + expect(tokenFromCallback(link)).toBeNull(); + }); + + it("recognizes the callback whatever it carries, so no link reaches the router", () => { + expect(isHandoffCallback(`${HANDOFF_CALLBACK_URL}?token=a&token=b`)).toBe(true); + expect(isHandoffCallback("objectos://auth/callback?token=a")).toBe(false); + }); +}); + +describe("buildRedeemScript", () => { + function run(script: string, origin: string, answer: Promise<{ ok: boolean }>) { + const fetchMock = jest.fn(() => answer); + const replace = jest.fn(); + new Function("location", "fetch", script)({ origin, replace }, fetchMock); + return { fetchMock, replace }; + } + + const script = () => buildRedeemScript(SERVER, "TOKEN", "VERIFIER")!; + + it("posts T.code_verifier to the verify route with the WebView's own cookies", async () => { + const { fetchMock, replace } = run(script(), SERVER, Promise.resolve({ ok: true })); + expect(fetchMock).toHaveBeenCalledWith("/api/v1/auth/one-time-token/verify", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: "TOKEN.VERIFIER" }), + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(replace).toHaveBeenCalledWith("/_console/"); + }); + + it("sends nothing from any other origin", () => { + const { fetchMock, replace } = run( + script(), + "https://login.idp.example", + Promise.resolve({ ok: true }) + ); + expect(fetchMock).not.toHaveBeenCalled(); + expect(replace).not.toHaveBeenCalled(); + }); + + it("goes back to the sign-in page when the server refuses (a replay, an expired token)", async () => { + const { replace } = run(script(), SERVER, Promise.resolve({ ok: false })); + await new Promise((resolve) => setImmediate(resolve)); + expect(replace).toHaveBeenCalledWith(`/_console/login?error=${HANDOFF_FAILED_ERROR}`); + }); + + it("goes back to the sign-in page when the request fails", async () => { + const { replace } = run(script(), SERVER, Promise.reject(new Error("offline"))); + await new Promise((resolve) => setImmediate(resolve)); + expect(replace).toHaveBeenCalledWith(`/_console/login?error=${HANDOFF_FAILED_ERROR}`); + }); + + it("keeps a server's base path", async () => { + const { fetchMock, replace } = run( + buildRedeemScript(`${SERVER}/erp`, "T", "V")!, + SERVER, + Promise.resolve({ ok: true }) + ); + expect(fetchMock.mock.calls[0]).toEqual( + expect.arrayContaining(["/erp/api/v1/auth/one-time-token/verify"]) + ); + await new Promise((resolve) => setImmediate(resolve)); + expect(replace).toHaveBeenCalledWith("/erp/_console/"); + }); +}); + +describe("probeHandoffOffered", () => { + it.each([ + [401, true], + [400, false], + [404, false], + [500, false], + [200, false], + ])("reads %i as %s", async (status, offered) => { + await expect( + probeHandoffOffered(SERVER, jest.fn().mockResolvedValue({ status })) + ).resolves.toBe(offered); + }); + + it("asks anonymously, with this app's callback", async () => { + const fetchFn = jest.fn().mockResolvedValue({ status: 401 }); + await probeHandoffOffered(SERVER, fetchFn); + const [url, init] = fetchFn.mock.calls[0]; + expect(url.startsWith(`${SERVER}/api/v1/auth/one-time-token/handoff?`)).toBe(true); + expect(new URL(url).searchParams.get("redirect_uri")).toBe(HANDOFF_CALLBACK_URL); + expect(init.credentials).toBe("omit"); + }); + + it("reads a failed request as not offered", async () => { + await expect( + probeHandoffOffered(SERVER, jest.fn().mockRejectedValue(new Error("offline"))) + ).resolves.toBe(false); + }); +}); + +describe("runSystemSignIn", () => { + function deps(overrides: Partial = {}): SystemSignInDeps { + return { + offered: jest.fn().mockResolvedValue(true), + randomBytes: jest.fn((count: number) => new Uint8Array(count).fill(7)), + sha256Base64, + openAuthSession: jest + .fn() + .mockResolvedValue({ type: "success", url: `${HANDOFF_CALLBACK_URL}?token=T1` }), + ...overrides, + }; + } + + it("opens the session on the verifier's challenge and returns both halves", async () => { + const d = deps(); + const outcome = await runSystemSignIn(SERVER, d); + expect(outcome.kind).toBe("signed-in"); + if (outcome.kind !== "signed-in") return; + expect(outcome.token).toBe("T1"); + expect(outcome.codeVerifier).toMatch(/^[A-Za-z0-9\-._~]{43}$/); + const [entry, callback] = (d.openAuthSession as jest.Mock).mock.calls[0]; + expect(callback).toBe(HANDOFF_CALLBACK_URL); + const challenge = challengeFromBase64Digest(await sha256Base64(outcome.codeVerifier)); + expect(new URL(entry).searchParams.get("redirect")).toContain(`code_challenge=${challenge}&`); + expect(entry).not.toContain(outcome.codeVerifier); + }); + + it("opens nothing on a server that does not offer the hand-off", async () => { + const d = deps({ offered: jest.fn().mockResolvedValue(false) }); + await expect(runSystemSignIn(SERVER, d)).resolves.toEqual({ kind: "not-offered" }); + expect(d.openAuthSession).not.toHaveBeenCalled(); + }); + + it.each(["cancel", "dismiss", "locked"])("reads a %s session as cancelled", async (type) => { + const d = deps({ openAuthSession: jest.fn().mockResolvedValue({ type }) }); + await expect(runSystemSignIn(SERVER, d)).resolves.toEqual({ kind: "cancelled" }); + }); + + it("fails on a callback without a usable token", async () => { + const d = deps({ + openAuthSession: jest + .fn() + .mockResolvedValue({ type: "success", url: `${HANDOFF_CALLBACK_URL}?token=a&x=1` }), + }); + await expect(runSystemSignIn(SERVER, d)).resolves.toEqual({ kind: "failed" }); + }); + + it("fails, and never rejects, when the session throws", async () => { + const d = deps({ openAuthSession: jest.fn().mockRejectedValue(new Error("no browser")) }); + await expect(runSystemSignIn(SERVER, d)).resolves.toEqual({ kind: "failed" }); + }); +}); diff --git a/app.config.ts b/app.config.ts index 28b590b..de6a5fb 100644 --- a/app.config.ts +++ b/app.config.ts @@ -41,6 +41,8 @@ const PERMISSION_LOCALES: Record< // nor the ID can change without moving to a new EAS project. const EAS_PROJECT_ID = "40edd17e-752e-4062-a404-5eb8892ce47f"; +const APP_ID = "ai.objectos.mobile"; + export default ({ config }: ConfigContext): ExpoConfig => ({ ...config, name: "ObjectOS", @@ -53,7 +55,9 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ updates: { url: `https://u.expo.dev/${EAS_PROJECT_ID}` }, orientation: "portrait", icon: "./assets/icon.png", - scheme: process.env.EXPO_PUBLIC_APP_SCHEME ?? "objectos", + // App links, and the sign-in hand-off's callback (HANDOFF_APP_SCHEME in + // lib/shell/system-sign-in.ts), which must be a reverse-domain scheme. + scheme: [process.env.EXPO_PUBLIC_APP_SCHEME ?? "objectos", APP_ID], userInterfaceStyle: "automatic", // SDK 55 dropped legacy architecture — New Architecture is always on, so the // `newArchEnabled` flag was removed from ExpoConfig. @@ -76,7 +80,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ }, ios: { supportsTablet: true, - bundleIdentifier: "ai.objectos.mobile", + bundleIdentifier: APP_ID, }, android: { adaptiveIcon: { @@ -84,7 +88,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ backgroundColor: "#ffffff", }, // SDK 55 removed `edgeToEdgeEnabled` — edge-to-edge is always on now. - package: "ai.objectos.mobile", + package: APP_ID, // expo-screen-capture (the app lock's FLAG_SECURE) declares these for its // screenshot listener, which the shell does not use: the app detects no // screenshots, and pickImage() goes through the system photo picker, which diff --git a/app/+native-intent.tsx b/app/+native-intent.tsx index 73070c9..02240c6 100644 --- a/app/+native-intent.tsx +++ b/app/+native-intent.tsx @@ -7,9 +7,11 @@ import { redirectIncomingPath } from "~/lib/shell/incoming-link"; * picker, which probes the address and asks the user to confirm it; see * `lib/shell/incoming-link.ts`. */ -export function redirectSystemPath({ path }: { path: string; initial: boolean }): string { +export function redirectSystemPath({ path, initial }: { path: string; initial: boolean }): string { try { - return redirectIncomingPath(path); + const href = redirectIncomingPath(path); + // A callback dropped on a running app has no screen to stay on at launch. + return href === "" && initial ? "/" : href; } catch { return "/"; } diff --git a/components/shell/ConsoleWebView.tsx b/components/shell/ConsoleWebView.tsx index 2f53b26..40643bf 100644 --- a/components/shell/ConsoleWebView.tsx +++ b/components/shell/ConsoleWebView.tsx @@ -39,6 +39,8 @@ import { type ExcursionState, type ExcursionStep, } from "~/lib/shell/sign-in-excursion"; +import { buildRedeemScript } from "~/lib/shell/system-sign-in"; +import { openSystemSignIn } from "~/lib/shell/system-sign-in-session"; import type { RouteRule } from "~/lib/shell/route-owner"; import { BRIDGE_CAPABILITIES, @@ -323,6 +325,57 @@ export const ConsoleWebView = forwardRef clearReadyTimer, [clearReadyTimer]); useEffect(() => clearExcursionTimer, [clearExcursionTimer]); + // System sign-in (`lib/shell/system-sign-in.ts`): an identity provider that + // refuses embedded WebViews runs in the operating system's auth session. + // The token it brings back waits here until a Console is up to redeem it: + // the refusal has re-created the WebView at the sign-in page meanwhile. + const systemSignInBusyRef = useRef(false); + const pendingRedeemRef = useRef<{ + serverUrl: string; + token: string; + codeVerifier: string; + } | null>(null); + + const redeemPending = useCallback(() => { + const pending = pendingRedeemRef.current; + if (!pending || loadRef.current.phase !== "ready" || !webviewRef.current) return; + pendingRedeemRef.current = null; + // The user switched connection meanwhile: this token is another server's. + if (pending.serverUrl !== serverUrlRef.current) return; + const script = buildRedeemScript(pending.serverUrl, pending.token, pending.codeVerifier); + if (!script) return; + // The script moves the page either way, so the Console boots behind the cover. + dispatch({ type: "reload" }); + webviewRef.current.injectJavaScript(script); + }, [dispatch]); + useEffect(redeemPending, [phase, redeemPending]); + + const startSystemSignIn = useCallback(() => { + if (systemSignInBusyRef.current) return; + systemSignInBusyRef.current = true; + const server = serverUrlRef.current; + setExcursionNotice(null); + void openSystemSignIn(server).then((outcome) => { + systemSignInBusyRef.current = false; + // The outcome's kind only: its token and verifier are never logged. + addBreadcrumb({ + category: "shell", + message: "system sign-in ended", + data: { outcome: outcome.kind }, + }); + if (outcome.kind === "signed-in") { + pendingRedeemRef.current = { + serverUrl: server, + token: outcome.token, + codeVerifier: outcome.codeVerifier, + }; + redeemPending(); + } else if (outcome.kind !== "cancelled" && serverUrlRef.current === server) { + setExcursionNotice("unsupported"); + } + }); + }, [redeemPending]); + const applyExcursion = useCallback( (event: ExcursionEvent): ExcursionStep => { const previous = excursionRef.current; @@ -354,7 +407,8 @@ export const ConsoleWebView = forwardRef/_console/login?redirect=` in the auth session. + The user chooses the provider there. It is the Console's own page, so a + federation hop (a server signing in through ObjectOS Cloud) works as in a + browser. The session is ephemeral on iOS; Android's Custom Tabs have no such + mode through expo-web-browser. +4. After the sign-in the Console navigates to the hand-off, which answers + `302 ai.objectos.mobile://auth/callback?token=T`, the token bound to the + verifier's S256 challenge. +5. The shell redeems `T.code_verifier` from inside the WebView, with a script + that runs only on the server's origin, and the page moves to the Console's + start, or to `/login?error=handoff_failed`, whose banner says it did not + finish. The session cookie lands in the WebView's store, and the shell keeps + no credential: the token and the verifier are dropped once the script is sent + and are never logged. + +The guarantees are the server's. The token is single-use and lasts one minute, +so a replayed callback is refused. An app that registers the same scheme and +intercepts the callback holds `T` alone, which the server refuses without using +it up. The hand-off also takes only a session signed in within five minutes and +refuses a cross-site navigation, so an existing browser session is not enough, +and step 4 has to be the Console's navigation, not the last redirect of the +provider's chain. See objectstack `one-time-token-handoff.ts`. + +The callback's own scheme is the reverse-domain one (`HANDOFF_APP_SCHEME`), because `AuthConfig.trustedAppSchemes` accepts +nothing else. It is not an app link: `redirectIncomingPath` drops it, so its +token never becomes a router path. A callback the app did not ask for finds no +verifier and does nothing. So does one that arrives after the system killed the +app during the session: the user signs in again. + +A deployment turns this on by registering the scheme, as +[DEPLOYMENT.md](./DEPLOYMENT.md#system-sign-in) says. ### Managed deployment @@ -484,8 +527,9 @@ excursion. WebView with "Error 403: disallowed_useragent". The shell detects that refusal before the page loads: a request to `accounts.google.com`, or any excursion URL that carries the `disallowed_useragent` code. It then blocks - the navigation, goes back to `/login`, and says that this sign-in method is - not supported in the app yet. A provider that refuses by its own user-agent + the navigation, goes back to `/login`, and starts a system sign-in (above). On + a server that does not offer it, it says that this sign-in method is not + supported in the app yet. A provider that refuses by its own user-agent check without that code cannot be told apart. Its error page shows under the bar, and 取消 goes back. - **The bridge is closed.** An excursion origin is never on the bridge's trust diff --git a/lib/shell/incoming-link.ts b/lib/shell/incoming-link.ts index dd704c1..af5ddd4 100644 --- a/lib/shell/incoming-link.ts +++ b/lib/shell/incoming-link.ts @@ -14,6 +14,7 @@ * than on an expo-router "not found" page. */ import { normalizeServerUrl, safeConsoleRoute } from "./console-url"; +import { isHandoffCallback } from "./system-sign-in"; const SCHEME_LINK_RE = /^[a-z][a-z0-9+.-]*:\/\/([^/?#]*)([^?#]*)(\?[^#]*)?/i; @@ -134,12 +135,18 @@ export function consoleScreenHref(route: string, nonce?: string | number): strin * on our scheme land on the start screen, and anything that is not a scheme * URL (internal paths, the dev-client launcher) passes through. * + * The sign-in hand-off's callback goes nowhere (`""`, which expo-router drops). + * Android delivers it as an ordinary link while `openAuthSessionAsync` is + * listening for it, and the page under the auth session must stay. Its token + * never becomes a router path. + * * Each redirect carries a nonce (`now()`, injectable for tests) so opening the * same link twice navigates both times. */ export function redirectIncomingPath(path: string, now: () => number = Date.now): string { if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(path)) return path; if (path.includes("expo-development-client")) return path; + if (isHandoffCallback(path)) return ""; const route = routeFromIncomingLink(path); if (route) return consoleScreenHref(route, now()); const connect = connectRequestFromLink(path); diff --git a/lib/shell/sign-in-excursion.ts b/lib/shell/sign-in-excursion.ts index 580bcbf..a99ff4b 100644 --- a/lib/shell/sign-in-excursion.ts +++ b/lib/shell/sign-in-excursion.ts @@ -67,8 +67,7 @@ export const EXCURSION_TIMEOUT_MS = 10 * 60 * 1000; /** * Hosts whose sign-in refuses every embedded WebView. Google answers an * embedded user agent with its "Error 403: disallowed_useragent" page. That - * sign-in needs the system authentication session, which the app does not - * have yet. + * sign-in runs in the system authentication session (`system-sign-in.ts`). */ export const EMBEDDED_WEBVIEW_REFUSERS: readonly string[] = ["accounts.google.com"]; diff --git a/lib/shell/system-sign-in-session.ts b/lib/shell/system-sign-in-session.ts new file mode 100644 index 0000000..e63e653 --- /dev/null +++ b/lib/shell/system-sign-in-session.ts @@ -0,0 +1,26 @@ +/** + * {@link runSystemSignIn} with the app's real transports: the probe's XHR, the + * platform's random source and digest, and the operating system's auth session. + * + * The session is ephemeral where the platform offers it (iOS). The hand-off + * carries only a session signed in within the last five minutes, so a browser + * session from earlier is no use, and an ephemeral one asks for no consent + * prompt. Android's Custom Tabs have no such mode through expo-web-browser. + */ +import * as Crypto from "expo-crypto"; +import * as WebBrowser from "expo-web-browser"; +import { xhrFetch } from "~/lib/server-probe"; +import { probeHandoffOffered, runSystemSignIn, type SystemSignInOutcome } from "./system-sign-in"; + +export function openSystemSignIn(serverUrl: string): Promise { + return runSystemSignIn(serverUrl, { + offered: () => probeHandoffOffered(serverUrl, xhrFetch), + randomBytes: (count) => Crypto.getRandomBytes(count), + sha256Base64: (value) => + Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, value, { + encoding: Crypto.CryptoEncoding.BASE64, + }), + openAuthSession: (url, callbackUrl) => + WebBrowser.openAuthSessionAsync(url, callbackUrl, { preferEphemeralSession: true }), + }); +} diff --git a/lib/shell/system-sign-in.ts b/lib/shell/system-sign-in.ts new file mode 100644 index 0000000..33a0747 --- /dev/null +++ b/lib/shell/system-sign-in.ts @@ -0,0 +1,243 @@ +/** + * System sign-in: a sign-in an identity provider refuses to run in an embedded + * WebView (Google answers `disallowed_useragent`) runs in the operating + * system's auth session instead, and its session is handed back to the WebView + * once, through the server's one-time-token hand-off. + * + * 1. The shell makes an RFC 7636 `code_verifier` and keeps it in memory. + * 2. It opens the Console's sign-in page in the auth session + * (`ASWebAuthenticationSession`, Custom Tabs), with the hand-off as the + * page's `redirect`: {@link signInEntryUrl}. + * 3. After the sign-in the Console navigates to the hand-off, which answers + * `302 ai.objectos.mobile://auth/callback?token=T`. The token is stored + * under a key bound to the verifier's S256 challenge. + * 4. The shell redeems `T.code_verifier` from inside the WebView, on the + * server's origin ({@link buildRedeemScript}), so the session cookie lands + * in the WebView's store. The shell holds no credential afterwards. + * + * An app that intercepts the callback holds `T` alone and cannot redeem it: the + * server answers `400 Invalid token` and the real holder can still redeem. + * The verifier never leaves the device except in that redemption. A replay is + * refused by the server: the token is single-use and lasts one minute. The + * server's half is objectstack `one-time-token-handoff.ts`. + * + * Everything here is pure, or takes what it touches as an argument. The + * component feeds it and acts on the outcome. + */ +import { CONSOLE_BASE_PATH, consoleUrl, originOf, parseUrl } from "./console-url"; +import { SIGN_IN_ROUTE } from "./navigation-policy"; +import { toScriptLiteral } from "./bridge/scripts"; + +/** + * The URL scheme the hand-off redirects to. A deployment registers this value in + * `AuthConfig.trustedAppSchemes`, which accepts only a reverse-domain private-use + * scheme (RFC 8252 §7.1), so the app's short link scheme (`objectos`) cannot be + * it. It is the app's identifier; `app.config.ts` registers it with the OS. + */ +export const HANDOFF_APP_SCHEME = "ai.objectos.mobile"; + +/** The hand-off route, under the server's base path. */ +export const HANDOFF_PATH = "/api/v1/auth/one-time-token/handoff"; + +/** The redemption route (the auth library's own), under the server's base path. */ +export const VERIFY_PATH = "/api/v1/auth/one-time-token/verify"; + +const CALLBACK_HOST = "auth"; +const CALLBACK_PATH = "/callback"; + +/** Where the hand-off sends the token. */ +export const HANDOFF_CALLBACK_URL = `${HANDOFF_APP_SCHEME}://${CALLBACK_HOST}${CALLBACK_PATH}`; + +/** How many random bytes make a verifier: 32 is RFC 7636 §4.1's recommendation, 43 characters. */ +export const CODE_VERIFIER_BYTES = 32; + +/** + * The `error` code a failed redemption sends the Console's sign-in page. The + * page shows its own "single sign-on could not be completed" banner for any + * callback error. + */ +export const HANDOFF_FAILED_ERROR = "handoff_failed"; + +/** The hand-off's token alphabet (the auth library's generator). */ +const TOKEN_RE = /^[A-Za-z0-9_-]+$/; + +/** An S256 challenge no verifier was kept for: {@link probeHandoffOffered} never redeems. */ +const PROBE_CHALLENGE = "A".repeat(43); + +const BASE64URL = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + +/** Unpadded base64url (RFC 4648 §5). */ +export function base64Url(bytes: Uint8Array): string { + let out = ""; + for (let i = 0; i < bytes.length; i += 3) { + const a = bytes[i]; + const b = i + 1 < bytes.length ? bytes[i + 1] : 0; + const c = i + 2 < bytes.length ? bytes[i + 2] : 0; + out += BASE64URL[a >> 2]; + out += BASE64URL[((a & 3) << 4) | (b >> 4)]; + if (i + 1 < bytes.length) out += BASE64URL[((b & 15) << 2) | (c >> 6)]; + if (i + 2 < bytes.length) out += BASE64URL[c & 63]; + } + return out; +} + +/** RFC 7636 §4.2 S256, from a base64 SHA-256 digest of the verifier's ASCII bytes. */ +export function challengeFromBase64Digest(digest: string): string { + return digest.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +/** The hand-off's path and query, as the Console's `redirect` carries it. */ +export function handoffTarget(serverUrl: string, challenge: string): string | null { + const server = parseUrl(serverUrl); + if (!server) return null; + const base = server.path === "/" ? "" : server.path; + return ( + `${base}${HANDOFF_PATH}?redirect_uri=${encodeURIComponent(HANDOFF_CALLBACK_URL)}` + + `&code_challenge=${challenge}&code_challenge_method=S256` + ); +} + +/** What the auth session opens: the Console's sign-in page, going to the hand-off afterwards. */ +export function signInEntryUrl(serverUrl: string, challenge: string): string | null { + const target = handoffTarget(serverUrl, challenge); + if (target === null) return null; + return consoleUrl(serverUrl, `${SIGN_IN_ROUTE}?redirect=${encodeURIComponent(target)}`); +} + +/** Whether `link` is the hand-off's callback, whatever it carries. */ +export function isHandoffCallback(link: string): boolean { + const target = parseUrl(link); + return ( + target !== null && + target.scheme === HANDOFF_APP_SCHEME && + target.host === CALLBACK_HOST && + target.path === CALLBACK_PATH + ); +} + +/** + * The token a hand-off callback carries, or `null` for anything else: another + * link, a repeated `token`, an extra parameter, or a value outside the token's + * alphabet. The callback has one shape, so a link with more is refused. + */ +export function tokenFromCallback(link: string): string | null { + if (!isHandoffCallback(link)) return null; + const query = link.split("#")[0].split("?")[1]; + if (!query) return null; + const pairs = query.split("&"); + if (pairs.length !== 1 || !pairs[0].startsWith("token=")) return null; + const token = pairs[0].slice("token=".length); + return TOKEN_RE.test(token) ? token : null; +} + +/** + * The script that redeems the token in the WebView. It runs only on the + * server's origin, so a verifier is never sent anywhere else, and it moves the + * page either way: to the Console's start with the new session, or back to the + * sign-in page with {@link HANDOFF_FAILED_ERROR}. + */ +export function buildRedeemScript( + serverUrl: string, + token: string, + codeVerifier: string +): string | null { + const origin = originOf(serverUrl); + const server = parseUrl(serverUrl); + if (origin === null || !server) return null; + const base = server.path === "/" ? "" : server.path; + const home = `${base}${CONSOLE_BASE_PATH}/`; + const failed = `${base}${CONSOLE_BASE_PATH}${SIGN_IN_ROUTE}?error=${HANDOFF_FAILED_ERROR}`; + return `(function () { + if (location.origin !== ${toScriptLiteral(origin)}) return; + var failed = ${toScriptLiteral(failed)}; + fetch(${toScriptLiteral(`${base}${VERIFY_PATH}`)}, { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: ${toScriptLiteral(JSON.stringify({ token: `${token}.${codeVerifier}` }))} + }).then( + function (response) { location.replace(response.ok ? ${toScriptLiteral(home)} : failed); }, + function () { location.replace(failed); } + ); +})(); +true;`; +} + +/** The slice of the probe's fetch transport this reads. */ +export type HandoffProbeFetch = ( + url: string, + init: { method: "GET"; headers: Record; credentials: "omit"; signal: AbortSignal } +) => Promise<{ status: number }>; + +/** + * Whether the server hands a sign-in to this app, asked anonymously so nobody + * is sent into a sign-in that cannot come back. The route checks the scheme + * before the session, so `401` means "registered, sign in first". `404` is a + * server without the route, `400` a server that has not registered this app. + */ +export async function probeHandoffOffered( + serverUrl: string, + fetchFn: HandoffProbeFetch, + timeoutMs = 8000 +): Promise { + const target = handoffTarget(serverUrl, PROBE_CHALLENGE); + const server = parseUrl(serverUrl); + if (target === null || !server) return false; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetchFn(`${server.scheme}://${server.host}${target}`, { + method: "GET", + headers: { Accept: "application/json" }, + credentials: "omit", + signal: controller.signal, + }); + return response.status === 401; + } catch { + return false; + } finally { + clearTimeout(timer); + } +} + +/** What the app's auth session answers, as `openAuthSessionAsync` reports it. */ +export type AuthSessionResult = { type: "success"; url: string } | { type: string }; + +export interface SystemSignInDeps { + /** {@link probeHandoffOffered}, on this server. */ + offered: () => Promise; + randomBytes: (count: number) => Uint8Array; + /** Base64 SHA-256 of a string's bytes. */ + sha256Base64: (value: string) => Promise; + openAuthSession: (url: string, callbackUrl: string) => Promise; +} + +export type SystemSignInOutcome = + /** The token and the verifier that redeems it. Neither is ever logged. */ + | { kind: "signed-in"; token: string; codeVerifier: string } + /** The user closed the auth session. */ + | { kind: "cancelled" } + /** This server does not hand sign-ins to this app. */ + | { kind: "not-offered" } + /** The auth session failed, or came back with something other than a token. */ + | { kind: "failed" }; + +/** Run the auth session, from the probe to the callback. Never rejects. */ +export async function runSystemSignIn( + serverUrl: string, + deps: SystemSignInDeps +): Promise { + try { + if (!(await deps.offered())) return { kind: "not-offered" }; + const codeVerifier = base64Url(deps.randomBytes(CODE_VERIFIER_BYTES)); + const challenge = challengeFromBase64Digest(await deps.sha256Base64(codeVerifier)); + const entry = signInEntryUrl(serverUrl, challenge); + if (entry === null) return { kind: "failed" }; + const result = await deps.openAuthSession(entry, HANDOFF_CALLBACK_URL); + if (result.type !== "success" || !("url" in result)) return { kind: "cancelled" }; + const token = tokenFromCallback(result.url); + return token === null ? { kind: "failed" } : { kind: "signed-in", token, codeVerifier }; + } catch { + return { kind: "failed" }; + } +} diff --git a/package.json b/package.json index d8aa2e2..1e5ab9f 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "expo": "~55.0.26", "expo-camera": "~55.0.23", "expo-constants": "~55.0.16", + "expo-crypto": "~55.0.19", "expo-dev-client": "~55.0.41", "expo-device": "~55.0.17", "expo-file-system": "~55.0.22", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2817eaa..0d29214 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: expo-constants: specifier: ~55.0.16 version: 55.0.16(expo@55.0.26)(react-native@0.83.6(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.0)) + expo-crypto: + specifier: ~55.0.19 + version: 55.0.19(expo@55.0.26) expo-dev-client: specifier: ~55.0.41 version: 55.0.41(expo@55.0.26) @@ -2833,6 +2836,11 @@ packages: expo: '*' react-native: '*' + expo-crypto@55.0.19: + resolution: {integrity: sha512-Ip1jQ5GphvrC4Ls5osAmIapi+JZXOqRJYaaMxTFWpkhssNUq88oP45pWGv2uU2K/Ci6j7iLDJzU8eN3v+B7jrA==} + peerDependencies: + expo: '*' + expo-dev-client@55.0.41: resolution: {integrity: sha512-nEVrGdcsDMimSNGDTsNeQsV4KpL63JS8y75azINvwEULaGOZ6mutlFUxed3bsPvhCWc/oT7861XAEWsxpHFBbw==} peerDependencies: @@ -9063,6 +9071,10 @@ snapshots: transitivePeerDependencies: - supports-color + expo-crypto@55.0.19(expo@55.0.26): + dependencies: + expo: 55.0.26(@babel/core@7.29.7)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.16)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.6(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.7)(react-native@0.83.6(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-dev-client@55.0.41(expo@55.0.26): dependencies: expo: 55.0.26(@babel/core@7.29.7)(@expo/dom-webview@55.0.6)(@expo/metro-runtime@55.0.11)(expo-router@55.0.16)(react-dom@19.2.0(react@19.2.0))(react-native-webview@13.16.0(react-native@0.83.6(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.7)(react-native@0.83.6(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.7)(@types/react@19.2.13)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)