mirror of
https://github.com/priyanshujain/margin-calendar.git
synced 2026-10-02 11:07:04 +00:00
Sign in on a phone with no console work, in the browser's own session
Mobile OAuth reused the desktop client all along; what stopped it was the browser. Sending the user out to Safari or Chrome backgrounds the app, iOS suspends it, and the redirect carrying the code arrives at a socket nobody is accepting on. The consent page now opens in front of the app instead, in SFSafariViewController or a Chrome Custom Tab, so the loopback listener stays live and the existing `installed` client is enough. Verified against Google's real consent screen on a simulator and an emulator. A per-platform client is still supported and is now an upgrade rather than a prerequisite. On iOS it buys ASWebAuthenticationSession, which shares Safari's session so nobody is asked to sign in to Google twice. Android needs nothing: Custom Tabs share Chrome's cookies, measured rather than assumed. iOS session sharing could not be confirmed on the simulator and wants a real device. Never an app-owned WebView: Google blocks it, and rightly, since a webview the app controls can read the password typed into it. Cancelling is no longer reported as a failure. AuthEvent carries a `cancelled` flag, set by comparing against the constant every back-out path returns, and Google's `access_denied` on desktop counts too. Five frontend bugs found by driving the real UI, not by reading it: the details card slid under the tab bar leaving its buttons unhittable; the ghost click after a touch pressed a button in the card that tap had just opened, opening the editor by itself; the swipe that pages the day was dead over every read-only block; 84px of macOS traffic-light lane was reserved on platforms with no traffic lights; and the desktop header ignored the top safe area on an iPad. A first launch now says what to do next rather than showing an empty grid, and accounts are named as Google accounts throughout.
This commit is contained in:
1 parent
661100dfdc
commit
d4c3a304b5
31 files changed
+1319
-114
No files matched your search
+25
-18
@@ -11,27 +11,34 @@ locked to `ipc:` exactly as margin has it.
|
||||
|
||||
## Authentication
|
||||
|
||||
Lifted from `margin/src-tauri/src/gdrive.rs`, then split in two when mobile arrived. Both halves
|
||||
build the same consent URL with PKCE S256 and a CSRF state parameter and open it in the system
|
||||
browser through the opener plugin, never an in-app webview: Google blocks the embedded-webview
|
||||
flow, and it deserves to be blocked, because a webview the app controls can read the password
|
||||
typed into it. They differ only in how the answer comes back.
|
||||
Lifted from `margin/src-tauri/src/gdrive.rs`. Every platform builds the same consent URL with PKCE
|
||||
S256 and a CSRF state parameter, and every platform opens it in the system's browser, never in a
|
||||
webview this app owns: Google blocks the embedded-webview flow, and it deserves to be blocked,
|
||||
because a webview the app controls can read the password typed into it.
|
||||
|
||||
Desktop binds a listener on `127.0.0.1:0` and catches the redirect on the loopback socket. The
|
||||
verifier lives on that listener's stack for the two minutes it is alive.
|
||||
The default flow is the same one on all five platforms. Bind a listener on `127.0.0.1:0`, ask
|
||||
Google to redirect there, and catch the code on the loopback socket. Google allows a Desktop client
|
||||
any loopback port without registering it, and the token endpoint checks the client id, the secret
|
||||
and the redirect rather than the operating system, so a phone can use the desktop client too. What
|
||||
makes that safe to rely on is where the browser is: on mobile the consent page opens **in front of**
|
||||
the app, in `SFSafariViewController` or a Chrome Custom Tab, so this process stays foreground and
|
||||
its listener stays live. Sending the user out to Safari would suspend it and the redirect would
|
||||
arrive at a socket nobody is accepting on. `google/browser.rs` is that surface, and dismissing it
|
||||
once the code lands.
|
||||
|
||||
Mobile cannot do that. iOS will not keep a background listener alive dependably, and Google
|
||||
rejects loopback redirects for Android and iOS client types outright, so the redirect is a custom
|
||||
URI scheme the OS routes back to the app through `tauri-plugin-deep-link`. There is no listener to
|
||||
hold the verifier, the browser is a separate app and this process may be backgrounded while the
|
||||
user consents, so the verifier waits in `AuthState.pending` and `handle_redirect` picks it up
|
||||
whenever the link lands. It is taken rather than read, so a replayed link cannot start a second
|
||||
exchange.
|
||||
The second flow runs where a platform has been given its own OAuth client, which is Google's stated
|
||||
guidance and what to fall back on if they ever enforce it. Those clients are public, have no secret,
|
||||
and redirect to a custom URI scheme rather than to loopback. The verifier has no listener stack to
|
||||
live on, so it waits in `AuthState.pending` until the callback lands, and is taken rather than read
|
||||
so a replayed link cannot start a second exchange.
|
||||
|
||||
That split forces one more difference. A desktop client is confidential and has a secret; an
|
||||
Android or iOS client is public and has none, so `google-credentials.json` carries up to three
|
||||
clients and the build embeds the one for the platform it is compiling for. Details and the
|
||||
console steps are in [mobile.md](mobile.md).
|
||||
Where the callback lands differs. Android opens the external browser and the OS routes the scheme
|
||||
back through `tauri-plugin-deep-link`, which is also the arrival route on a cold start. iOS uses
|
||||
`ASWebAuthenticationSession`, which reports the URL straight to a completion handler and is the only
|
||||
iOS surface that shares Safari's cookies, so an account already signed in on the phone is offered by
|
||||
name. That last point is the reason the choice is not merely academic on iOS, and
|
||||
[mobile.md](mobile.md) argues it out. `load_credentials` decides once, by whether the block is in
|
||||
the file, and everything downstream follows from that.
|
||||
|
||||
The scope is `https://www.googleapis.com/auth/calendar` plus `openid email`. Calendar is a
|
||||
sensitive scope, so an unverified client shows the unverified-app interstitial and caps at 100
|
||||
|
||||
+118
-12
@@ -4,14 +4,72 @@ The same Rust core and the same React app, with a different shape of chrome and
|
||||
getting a token back from Google. Nothing is forked: the layout switches on a `data-phone`
|
||||
attribute and the OAuth flow switches on `cfg(mobile)`.
|
||||
|
||||
## Google will not let you reuse the desktop client
|
||||
## Signing in, and what console work buys you
|
||||
|
||||
This is the part that cannot be automated away, so do it first or nothing will sign in.
|
||||
Both platforms sign in out of the box with the `installed` client the desktop build already uses.
|
||||
Android is finished at that point. iOS works, but signs the user in from scratch, and one OAuth
|
||||
client fixes it. That asymmetry is the whole of this section.
|
||||
|
||||
Google issues OAuth clients per platform and refuses one in another's place. The desktop client
|
||||
redirects to a loopback port; Google rejects loopback redirects for Android and iOS client types
|
||||
outright, and iOS will not dependably keep a listener alive to catch one anyway. So a phone build
|
||||
redirects to a custom URI scheme instead, and needs its own client to do it.
|
||||
### The flow that needs nothing
|
||||
|
||||
A phone uses the desktop client, secret and all, and catches Google's answer on the same loopback
|
||||
listener the desktop flow binds. Two facts make that work: a Desktop client may redirect to loopback
|
||||
on **any** port with nothing registered in advance, and Google's token endpoint validates the client
|
||||
id, the secret and the redirect URI without any way of knowing which operating system is asking.
|
||||
Client types are policy guidance rather than a protocol check.
|
||||
|
||||
What used to make this impossible on a phone was not the protocol. It was that opening the consent
|
||||
page in Safari or Chrome sends the user to another app, iOS suspends this process, and a suspended
|
||||
process is not accepting on its socket, so the redirect carrying the code arrives at nobody. The
|
||||
consent page now opens **in front of** the app instead, in the system's own browser component:
|
||||
`SFSafariViewController` on iOS, a Chrome Custom Tab on Android. This app stays foreground and its
|
||||
listener stays live. `src-tauri/src/google/browser.rs` is all of it.
|
||||
|
||||
Still the system browser, note, and never a WebView this app owns. Google blocks that outright with
|
||||
`disallowed_useragent`, and it deserves to be blocked, because a webview the app controls can read
|
||||
the password typed into it. A Safari sheet or a Custom Tab is a different process with the browser's
|
||||
own cookies and autofill and an address bar the app cannot forge.
|
||||
|
||||
**Be clear about the trade.** Reusing a Desktop client from a phone is off Google's stated guidance,
|
||||
which says to create an Android or iOS client per platform. It works because the protocol does not
|
||||
check, not because Google blesses it. If Google ever starts enforcing the guidance the symptom will
|
||||
be a rejected token exchange, and the fix is a per-platform client, which the code already supports
|
||||
in full.
|
||||
|
||||
### Why Android needs nothing else
|
||||
|
||||
A Custom Tab is Chrome. It reads Chrome's cookie jar, so an account already signed in there is
|
||||
offered by name and there is no password to type. Loopback and shared session at once, for nothing.
|
||||
|
||||
### Why iOS wants an `ios` client
|
||||
|
||||
`SFSafariViewController` has not shared cookies with Safari since iOS 11: every app gets its own
|
||||
storage. So the no-setup iOS flow puts a real Safari view in front of the user with an empty cookie
|
||||
jar behind it, and Google has no idea who they are. It signs in. It just asks for a full login every
|
||||
time the token store is emptied.
|
||||
|
||||
`ASWebAuthenticationSession` is the API Apple shipped for exactly this, and it is the only one that
|
||||
shares Safari's session, which is why iOS puts up its own "wants to use google.com to sign in"
|
||||
prompt before it opens. But it intercepts a custom scheme and nothing else, never an http loopback
|
||||
redirect, so on iOS a shared session and loopback are mutually exclusive. Adding an `ios` block is
|
||||
what buys the scheme, and with it the good version.
|
||||
|
||||
So `auth.rs` runs three flows, not two: loopback everywhere by default, `ASWebAuthenticationSession`
|
||||
plus the custom scheme when there is an `ios` client, and the external browser plus the deep link
|
||||
when there is an `android` one. `load_credentials` decides once, by whether the block is in the
|
||||
file.
|
||||
|
||||
An iOS client is about a minute of work: it wants the bundle identifier and nothing else, no
|
||||
fingerprint, no keystore. An Android client wants a SHA-1 per signing key and buys nothing, so
|
||||
there is no reason to make one unless Google forces the issue.
|
||||
|
||||
One thing observed rather than assumed, and worth knowing before anyone re-tests this on a
|
||||
simulator: a cookie set in the simulator's Safari showed up in neither surface, including
|
||||
`ASWebAuthenticationSession` after its sharing prompt was accepted. The prompt appears and the
|
||||
plumbing is right, so this looks like the simulator not backing the shared jar rather than the API.
|
||||
Judge the session sharing on a real device.
|
||||
|
||||
## Creating the per-platform clients
|
||||
|
||||
Both mobile clients are **public**: no client secret exists, and PKCE is the only thing between an
|
||||
intercepted authorization code and a token. That is why the verifier is not optional anywhere in
|
||||
@@ -34,7 +92,7 @@ In the Google Cloud console, on the same project that already has the Calendar A
|
||||
2. Create an OAuth client of type **iOS**. It wants the bundle identifier, which is also
|
||||
`studio.margin.calendar`.
|
||||
|
||||
3. Put both client ids in `google-credentials.json` alongside the desktop one, matching
|
||||
3. Put the client ids in `google-credentials.json` alongside the desktop one, matching
|
||||
`google-credentials.example.json`:
|
||||
|
||||
```json
|
||||
@@ -45,12 +103,17 @@ In the Google Cloud console, on the same project that already has the Calendar A
|
||||
}
|
||||
```
|
||||
|
||||
The file is gitignored and embedded at build time. A build with no `android` or `ios` block
|
||||
compiles and then says so at runtime rather than failing to compile, which is the same bargain
|
||||
the desktop client already had.
|
||||
The file is gitignored and embedded at build time. Adding a block for one platform leaves the
|
||||
other on loopback: the choice is per platform, not per file.
|
||||
|
||||
A refresh token belongs to the client that obtained it, so adding or removing a block invalidates
|
||||
whatever is already stored on that device. Disconnect and reconnect the account afterwards.
|
||||
|
||||
## The redirect schemes
|
||||
|
||||
These matter only for the flow above. The loopback flow redirects to `http://127.0.0.1:PORT` and
|
||||
never touches a scheme.
|
||||
|
||||
Android redirects to `studio.margin.calendar:/oauth2redirect`. That is the package name, which is
|
||||
Google's documented form for an Android client, and being known at build time means it can sit in
|
||||
`AndroidManifest.xml` permanently rather than being pasted in per install.
|
||||
@@ -75,10 +138,47 @@ when the `mobile` array is empty it deletes the key outright. That one empty arr
|
||||
callback was dead on both platforms at first, so if a deep link ever stops arriving, look here
|
||||
before anywhere else.
|
||||
|
||||
The `studio.margin.calendar` scheme stays registered on both platforms whether or not anything
|
||||
uses it, because the OS is told about a scheme at install time and cannot be told about one later.
|
||||
|
||||
If the console shows you something different from either default, put it in the client's block in
|
||||
`google-credentials.json` as `redirect_uri` and it wins over both. Whatever you put there still
|
||||
has to have its scheme registered above, or the OS has no reason to hand the link to this app.
|
||||
|
||||
## The consent browser, and taking it away again
|
||||
|
||||
Neither surface closes itself once the redirect has landed, so `browser.rs` dismisses both. What is
|
||||
on screen by then is the listener's own "you can close this" page, and leaving it up would look
|
||||
like a sign-in that hung while the token exchange quietly succeeded behind it.
|
||||
|
||||
On iOS that is a `dismissViewControllerAnimated:` on the main thread. `SFSafariViewController` has
|
||||
no objc2 binding, since `objc2-safari-services` covers only the macOS extension API, so the class
|
||||
is reached by name and the SafariServices framework is linked by hand to put it in the process at
|
||||
all. The controller and its delegate are both retained for the length of the flow: UIKit holds a
|
||||
delegate weakly, and a controller nothing retains deallocates mid-sign-in.
|
||||
|
||||
On Android a Custom Tab belongs to Chrome and cannot be closed by the app that launched it. What
|
||||
works instead is starting `MainActivity` with `FLAG_ACTIVITY_CLEAR_TOP | FLAG_ACTIVITY_SINGLE_TOP`,
|
||||
which brings this app back to the front of the task the tab was launched into and pops the tab off
|
||||
on the way. Same move AppAuth makes.
|
||||
|
||||
Closing the browser by hand is the other exit, and it has to be noticed or the accounts panel waits
|
||||
on a sign-in that will never arrive. iOS gets it directly from `safariViewControllerDidFinish:`.
|
||||
Android has no such callback, so it reads a process resume while a consent attempt is in flight,
|
||||
which means the tab is gone: the tab is in this app's own task, and dismissal is the only way out
|
||||
of it. Either way `await_code` sees the flag on its next poll and gives up.
|
||||
|
||||
`ASWebAuthenticationSession` needs none of that. It takes itself away when the callback scheme
|
||||
matches and reports a cancel as error code 1, so the completion handler is the whole of it. What it
|
||||
does need is to be retained: a session nothing holds deallocates and then never calls back, which
|
||||
is the classic way to lose an afternoon to that API, and its presentation context provider is held
|
||||
weakly so it goes the same way. Both are kept in `browser.rs` until the next attempt replaces them,
|
||||
rather than being released inside the completion handler that the session itself owns.
|
||||
|
||||
One thing the Rust side cannot do anything about: a cancel still reaches the accounts panel as a
|
||||
failed connect, because `AuthEvent` has no third shape, so it reads as "Sign-in was cancelled."
|
||||
under a toast rather than as nothing at all.
|
||||
|
||||
## Toolchain
|
||||
|
||||
iOS needs Xcode and the iOS Rust targets:
|
||||
@@ -109,8 +209,14 @@ pnpm tauri android dev # emulator or attached device
|
||||
pnpm tauri android build
|
||||
```
|
||||
|
||||
The generated projects are committed. Re-running `init` overwrites them, so check afterwards that
|
||||
`MainActivity.kt` still publishes the window insets, since nothing else on Android does.
|
||||
The generated projects are committed, and three files in the Android one are edited by hand.
|
||||
`MainActivity.kt` publishes the window insets and launches the consent tab, `app/build.gradle.kts`
|
||||
carries the `androidx.browser` dependency the tab needs, and `AndroidManifest.xml` carries a
|
||||
`<queries>` element without which Android 11 and up hide every browser from
|
||||
`CustomTabsClient.getPackageName` and the consent page quietly falls back to a separate browser
|
||||
app. Re-running `android init` overwrites all three and nothing else supplies any of them, so check
|
||||
for them afterwards: without the insets the bars overlap the page, and without the bridge tapping
|
||||
Connect opens nothing at all.
|
||||
|
||||
Both `dev` commands start Vite themselves and fail outright if port 1430 is already taken, which
|
||||
it will be if a browser dev server is still up. Kill it, or pass a different port through
|
||||
|
||||
Reference in new issue
Block a user