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)