diff --git a/CHANGELOG.md b/CHANGELOG.md index 31ca7e4..d62bc3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Web, Android and iOS apps per profile.** A profile now holds a list of the + project's apps instead of a single API key and App ID, and an **App** + dropdown in the top bar picks the one the ID-token and App Check tabs use. + **Load apps** imports every app in the project, with its API key, package + name and SHA-1 or bundle ID, through the Firebase Management API; apps can + also be added by hand. +- **API keys restricted to an Android or iOS app now work.** Requests made with + such an app's key send `X-Android-Package` and `X-Android-Cert`, or + `X-Ios-Bundle-Identifier`, the way the Firebase SDKs do. Previously only the + key itself was sent, so Google rejected any key with an app restriction. + - **A user guide**, published at . It walks through setting up a Firebase project, every tab, profiles and the Users panel, and @@ -16,8 +27,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 screenshots come from a real demo project, and the README now opens with a short recording of the main flow. +### Changed + +- The persistence checkbox is now **remember API keys & debug tokens**, and it + no longer covers App IDs. App names, App IDs, package names, SHA-1s and + bundle IDs are always saved: they are public, and an app list that forgot + them on every restart would be of little use. API keys and App Check debug + tokens remain opt-in. +- Existing settings migrate on first launch: each profile's API key, App ID and + debug token become its first app, with the type taken from the App ID. + ### Fixed +- **Clear all claims failed** with `INVALID_CLAIMS : Not a JSON Object: null`. + It sent an empty `customAttributes` string, which the Identity Toolkit API now + rejects; it now sends `{}`, as the Admin SDKs do. +- The last rows of a long **Decoded JWT claims** table could fall below the + window with no way to scroll to them. Each tab now scrolls as a whole. +- A refused **Save** on the User Custom Claims tab (for example, over the + 1,000-byte limit) no longer shows the previous save's "Saved." message next to + the error. - `README.md` and `SECURITY.md` called the persistence checkbox "Remember secrets"; its label is **remember API key & App ID**. They also left out that the App Check debug token is gated by the same checkbox, and `SECURITY.md` diff --git a/Cargo.lock b/Cargo.lock index e3d0cc9..f7082ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1274,6 +1274,7 @@ dependencies = [ "rand 0.8.6", "reqwest", "rfd", + "ron", "rsa", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index d064064..9d75bae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,6 +65,9 @@ arboard = "3" # private key needs to be committed to the repository. rsa = { version = "0.9", features = ["pem"] } rand = "0.8" +# Parses saved-settings fixtures in the config migration tests. Already in the +# tree through eframe's persistence feature; keep the version in step with it. +ron = "0.11" [build-dependencies] winresource = "0.1" diff --git a/README.md b/README.md index f9ea6bd..b5438a7 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A native desktop GUI for the Firebase auth tokens you need during development an [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) [![Docs](https://img.shields.io/badge/docs-user%20guide-blue.svg)](https://smaranjit.github.io/firebase-token-toolkit/) -![Loading users, picking one, and generating an ID token](docs/src/images/quick-start.gif) +![Loading the project's apps, picking the Android app and a user, and generating an ID token](docs/src/images/quick-start.gif) **[Read the user guide →](https://smaranjit.github.io/firebase-token-toolkit/)** It covers setup, every tab, profiles, and troubleshooting. @@ -41,7 +41,8 @@ xattr -dr com.apple.quarantine "/Applications/Firebase Token Toolkit.app" ## Features -- **Multiple profiles** — switch between Firebase projects (dev/staging/prod, multiple clients, …) from a dropdown in the top bar. Each profile remembers its own service-account path, project ID, API key, App ID, and debug token. Switching a profile reloads the service account and clears the user list, OAuth cache, selected UID, and all tab outputs. +- **Multiple profiles** — switch between Firebase projects (dev/staging/prod, multiple clients, …) from a dropdown in the top bar. Each profile remembers its own service-account path, project ID, and apps. Switching a profile reloads the service account and clears the user list, OAuth cache, selected UID, and all tab outputs. +- **Web, Android and iOS apps** — **Load apps** imports the project's apps with their API keys through the Firebase Management API, or add them by hand. Requests made with an Android or iOS app's key send its package name and SHA-1, or bundle ID, so keys restricted to that app work. - **UID -> Custom Token** — sign a Firebase custom token (RS256) using a service account, with optional custom claims. - **UID -> ID Token** — one-shot: sign a custom token, then exchange it via `signInWithCustomToken`. - **Custom -> ID Token** — paste a custom token and exchange it for an ID token. @@ -49,17 +50,17 @@ xattr -dr com.apple.quarantine "/Applications/Firebase Token Toolkit.app" - **App Check** — exchange a registered debug token via `firebaseappcheck.googleapis.com`. - **Dynamic UID picker** — a left-side panel lists every user via `accounts:batchGet` (paginated up to 5,000) and lets you search by email, phone, or UID. Direct lookups fall through to `accounts:lookup` for users not present in the loaded page. - Decoded JWT claims table for every token, with timestamp → ISO conversion and clipboard copy. -- Persists project ID, service-account path, and last-used tab between launches. API keys, App IDs and App Check debug tokens are persisted **only if you opt in** — see [Security](#security). +- Persists project ID, service-account path, and last-used tab between launches. API keys and App Check debug tokens are persisted **only if you opt in** — see [Security](#security). ## Configure In the top bar: 1. **Service account** → *Browse* and pick the JSON from Firebase Console → Project Settings → Service accounts → *Generate new private key*. The project ID auto-populates from the file. -2. **API key** → Firebase Console → Project Settings → General → Web API Key. Only needed for the ID-token and App Check tabs. -3. **App ID** → only needed for the App Check tab. +2. **Load apps** → imports the project's web, Android and iOS apps with their App IDs and API keys. Or use **+ Add app** and paste an API key and App ID from Firebase Console → Project Settings → General → Your apps. +3. Pick the app to use from the **App** dropdown. Its API key is used by the ID-token and App Check tabs. -The status indicator next to the service account turns green and reads **ready** once the service account is loaded and an API key is set. The [quick start](https://smaranjit.github.io/firebase-token-toolkit/quick-start.html) walks through it with screenshots. +The status indicator next to the service account turns green and reads **ready** once the service account is loaded and the selected app has an API key. The [quick start](https://smaranjit.github.io/firebase-token-toolkit/quick-start.html) walks through it with screenshots. ## Build from source @@ -89,7 +90,7 @@ To reproduce the packaged release artifacts, see [`scripts/`](https://github.com This tool handles Firebase **service-account private keys** and mints signed tokens with them. Please read [SECURITY.md](https://github.com/smaranjit/firebase-token-toolkit/blob/main/SECURITY.md) before using it against anything that matters — it covers what is written to disk, what stays in memory, and how to report a vulnerability. -The short version: the service account is read from the path you pick and never copied; OAuth access tokens live in memory only; API keys, App IDs and App Check debug tokens are written to disk only when you tick **remember API key & App ID**. +The short version: the service account is read from the path you pick and never copied; OAuth access tokens live in memory only; API keys and App Check debug tokens are written to disk only when you tick **remember API keys & debug tokens**. ## Contributing diff --git a/README.txt b/README.txt index 9370bb2..171c766 100644 --- a/README.txt +++ b/README.txt @@ -34,13 +34,15 @@ SETTING IT UP Project Settings > Service accounts > Generate new private key. The project ID fills itself in from the file. - 2. API key. Firebase Console > Project Settings > General > Web API Key. - Only needed for the ID token and App Check tabs. + 2. Apps. Click Load apps to import the project's web, Android and iOS + apps with their API keys. Or click + Add app and paste an API key and + App ID from Firebase Console > Project Settings > General > Your apps. - 3. App ID. Only needed for the App Check tab. + 3. Pick the app to use from the App list. Its API key is used by the + ID token and App Check tabs. - The indicator in the top right turns green once the service account is - loaded and an API key is set. + The indicator turns green once the service account is loaded and the + selected app has an API key. IF NOTHING OPENS diff --git a/SECURITY.md b/SECURITY.md index f48bc25..258bc50 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -28,14 +28,16 @@ Persisted for every profile: on demand and never copied into the config. - Project ID, and the last-used tab. - Window size and position. +- Each app's name, type, App ID, Android package name and SHA-1, and iOS bundle + ID. These are public identifiers that ship inside every build of the app. -Persisted **only when "remember API key & App ID" is enabled**: +Persisted **only when "remember API keys & debug tokens" is enabled**: -- Web API key, App ID, and App Check debug token. +- Each app's API key and App Check debug token. These are stored in **plaintext**. There is no OS keychain integration — the file is protected by nothing but its filesystem permissions. If you would not paste the value -into a plaintext file, leave "remember API key & App ID" off. Turning it off clears those fields +into a plaintext file, leave "remember API keys & debug tokens" off. Turning it off clears those fields for *all* profiles on the next save (`clear_secrets_all` in `src/config.rs`). **Never persisted:** the service-account private key, OAuth access tokens, and any @@ -59,6 +61,13 @@ All requests go to Google endpoints over TLS (`rustls` — no OpenSSL): - `identitytoolkit.googleapis.com` — `signInWithCustomToken`, `accounts:batchGet`, `accounts:lookup`, `accounts:update` - `firebaseappcheck.googleapis.com` — `exchangeDebugToken` +- `firebase.googleapis.com` — `searchApps` and each app's config and SHA + certificates, when you click **Load apps**. The responses include the apps' + API keys, which are held like any other API key above. + +Requests made with an Android or iOS app's API key carry that app's package +name and SHA-1, or bundle ID, in the `X-Android-Package`, `X-Android-Cert` and +`X-Ios-Bundle-Identifier` headers, as the Firebase SDKs do. There is **no telemetry, analytics, or update check**. The application makes no network request you did not initiate. diff --git a/docs/src/configuration.md b/docs/src/configuration.md index f39fc72..c707a82 100644 --- a/docs/src/configuration.md +++ b/docs/src/configuration.md @@ -2,6 +2,8 @@ Everything the tabs need comes from the top bar. Its values belong to the active [profile](profiles.md), so switching profiles swaps them all at once. +The first rows hold the profile and service account; the last two hold the +selected [app](#apps). ![The top bar with a service account loaded and the indicator reading ready](images/top-bar-ready.png) @@ -26,9 +28,9 @@ right now: | Indicator | Meaning | Tabs available | |---|---|---| -| **not configured** (red) | No service account loaded | *Custom -> ID Token* and *App Check* only, given an API key | -| **SA only** (yellow) | Service account loaded, no API key | *UID -> Custom Token*, *User Custom Claims*, the Users panel | -| **ready** (green) | Service account and API key | All tabs (*App Check* also needs an App ID) | +| **not configured** (red) | No service account loaded | *Custom -> ID Token* and *App Check* only, given an app with an API key | +| **SA only** (yellow) | Service account loaded, the selected app has no API key | *UID -> Custom Token*, *User Custom Claims*, the Users panel | +| **ready** (green) | Service account, and the selected app has an API key | All tabs (*App Check* also needs the app's App ID) | ## Project ID @@ -36,22 +38,46 @@ The Firebase project ID, for example `fir-token-toolkit-demo`. The Users panel, User Custom Claims and App Check use it to build their requests. It normally comes from the key file, but you can edit it. -## API key +## remember API keys & debug tokens -The project's Web API key. The ID-token tabs and App Check send it to Google -with each request. The field is masked. - -## App ID - -The web app's App ID, used only by the App Check tab. - -## remember API key & App ID - -Off by default. When ticked, the API key, App ID and App Check debug token are -saved to disk in plaintext for **every** profile. When unticked, they are kept -in memory only and cleared from every profile the next time the app saves. See +Off by default. When ticked, every app's API key and App Check debug token are +saved to disk in plaintext, for **every** profile. When unticked, they are kept +in memory only and cleared from every profile the next time the app saves. +App names, App IDs and the Android and iOS identifiers are always saved: they +are not secret, and they ship inside every build of the app. See [Settings and storage](reference/settings-storage.md). +## Apps + +A profile holds a list of the project's apps, and the tabs that call Google +with an API key (*UID -> ID Token*, *Custom -> ID Token* and *App Check*) use +the one selected in the **App** dropdown. + +![The app rows for an Android app, showing its package name and SHA-1](images/top-bar-android.png) + +| Control | What it does | +|---|---| +| **App** dropdown | Selects the app, listed as `name (type)`. Switching clears the output of the three API-key tabs, but not the user list or the other tabs. | +| **name** | Renames the selected app. | +| **+ Add app** | Adds an empty **Web**, **Android** or **iOS** app and selects it. | +| **Remove** | Removes the selected app. Removing the last one leaves an empty web app. | +| **Load apps** | Imports the project's apps from Firebase. Needs the service account and Project ID. | +| **Type** | Web, Android or iOS. Decides which identifying headers go with the API key. | +| **API key** | The key sent with requests for this app. Masked. | +| **App ID** | The app's Firebase App ID. Pasting one sets **Type** to match, and renames an app still carrying a default name such as *Web app*. | +| **Package**, **SHA-1** | Android only: the package name and signing-certificate SHA-1. | +| **Bundle ID** | iOS only. | + +**Load apps** matches apps on their App ID, so running it again updates the +list rather than duplicating it. It refreshes each app's type, package, bundle +ID and API key from Firebase, but keeps a name or SHA-1 you changed yourself. + +The **Package** and **SHA-1**, or **Bundle ID**, matter only for an API key +restricted to an Android or iOS app; see +[API keys restricted to an app](firebase-setup.md#api-keys-restricted-to-an-app). +A SHA-1 that is not 40 hex digits (colons are fine) is marked *invalid* in +yellow and is not sent. + ## Selected UID Once you pick a user in the [Users panel](guide/users-panel.md), a **Selected diff --git a/docs/src/firebase-setup.md b/docs/src/firebase-setup.md index 58fff91..d68ec91 100644 --- a/docs/src/firebase-setup.md +++ b/docs/src/firebase-setup.md @@ -1,9 +1,10 @@ # Preparing your Firebase project -The app needs up to three things from your Firebase project. Which ones depend -on the tabs you plan to use: +The app needs up to three things from your Firebase project: a +service-account key, the project ID, and the details of the apps you test +with. Which ones depend on the tabs you plan to use: -| Tab | Service-account key | Project ID | Web API key | App ID | +| Tab | Service-account key | Project ID | App's API key | App ID | |---|:-:|:-:|:-:|:-:| | UID -> Custom Token | ✔ | for the Users panel | | | | UID -> ID Token | ✔ | for the Users panel | ✔ | | @@ -39,29 +40,63 @@ happens locally with the key and needs no extra role. The app fills this in from the service-account file, so you rarely need to type it. It is also shown under **Project settings** → **General**. -## Web API key - -Under **Project settings** → **General**, copy **Web API key**. If your project -has no web app yet, add one from the same page first. - -If you have restricted the key in Google Cloud Console, allow at least the +## Apps: web, Android and iOS + +Each [profile](profiles.md) holds a list of the project's apps, and the tabs +that need an API key use the one you select. Any app registered in the +project works, whatever its platform. + +**The quick way:** with the service-account key loaded, click **Load apps** in +the top bar. The app reads every web, Android and iOS app in the project +through the Firebase Management API and fills in each app's App ID, API key, +package name and SHA-1 (Android) or bundle ID (iOS). See +[The top bar](configuration.md#apps). + +The service account needs to be allowed to read Firebase apps +(`firebase.clients.list` and `firebase.clients.get`). The key the console +generates already is; for another account, the **Firebase Viewer** role +(`roles/firebase.viewer`) covers it. + +**By hand:** under **Project settings** → **General** → **Your apps**, each app +shows its **App ID**, for example `1:316659987175:android:b9522e663c7b203d12a842`. +Its API key is in the config file offered there (`google-services.json`, +`GoogleService-Info.plist`, or the web snippet's `apiKey`). The project-wide +**Web API Key** on the same page also works for any app, as long as it has no +application restriction. + +### API keys restricted to an app + +Google Cloud lets you restrict a key to particular Android apps (package name +plus signing-certificate SHA-1) or iOS apps (bundle ID). Google accepts such a +key only from a request that names the app, the way the Firebase SDKs do. The +app sends those details for you, taken from the selected app: + +| App type | Sent with the request | +|---|---| +| Web | the API key only | +| Android | `X-Android-Package` (package name) and `X-Android-Cert` (SHA-1) | +| iOS | `X-Ios-Bundle-Identifier` (bundle ID) | + +So for a restricted key, make sure the app's **Package** and **SHA-1**, or +**Bundle ID**, match one of the entries in the key's restriction. **Load apps** +fills them from Firebase, using the first SHA-1 certificate registered for an +Android app; if the key allows a different certificate, paste that one in. + +If you have restricted which **APIs** a key may call, allow at least the **Identity Toolkit API**, and the **Firebase App Check API** if you use the App Check tab. -## App ID (App Check only) - -Under **Project settings** → **General** → **Your apps**, copy the **App ID** of -the web app, which looks like `1:316659987175:web:ebcef8864368ab1a12a842`. - ## App Check debug token (App Check only) -The App Check tab exchanges a *debug token* you have registered for the app: +The App Check tab exchanges a *debug token* you have registered for the app. +This works the same for web, Android and iOS apps: 1. Register the app with App Check under **App Check** → **Apps** if you have not already. 2. Open the app's overflow menu (⋮) and choose **Manage debug tokens**. 3. Click **Add debug token**, give it a name, and either generate a token or - paste one your client printed. Copy the value; it is a UUID. + paste one your client printed (the browser console on the web, Logcat on + Android, the Xcode console on iOS). Copy the value; it is a UUID. Treat the debug token like a password. Anyone holding it can get valid App Check tokens for your app. diff --git a/docs/src/guide/app-check.md b/docs/src/guide/app-check.md index 5df1ee4..53595d1 100644 --- a/docs/src/guide/app-check.md +++ b/docs/src/guide/app-check.md @@ -5,12 +5,13 @@ for a real App Check token. Use it to call App Check–protected backends, such as Cloud Functions or your own server, from scripts or API tools that cannot run the App Check SDK. -**Needs:** the Project ID, App ID and Web API key from the top bar. No service -account is required. +**Needs:** the Project ID, and an [app](../configuration.md#apps) with an App +ID and API key. No service account is required. It works for web, Android and +iOS apps; the tab names the app it will use. ## Before you start -Register a debug token for your web app in the Firebase console under **App +Register a debug token for the app in the Firebase console under **App Check** → **Apps** → ⋮ → **Manage debug tokens**. The steps are in [Preparing your Firebase project](../firebase-setup.md#app-check-debug-token-app-check-only). @@ -38,9 +39,10 @@ configured for a different project rejects the token. ## Saving the debug token -The debug token belongs to the profile, but like the API key it is saved -between launches only when **remember API key & App ID** is ticked. Otherwise -you paste it again after restarting. +Each app keeps its own debug token, so switching apps switches tokens too. Like +API keys, debug tokens are saved between launches only when **remember API +keys & debug tokens** is ticked. Otherwise you paste it again after +restarting. ## When the exchange fails diff --git a/docs/src/guide/custom-claims.md b/docs/src/guide/custom-claims.md index 33f1401..43ab48d 100644 --- a/docs/src/guide/custom-claims.md +++ b/docs/src/guide/custom-claims.md @@ -39,7 +39,7 @@ Firebase rejects claims larger than 1,000 bytes. A counter under the editor measures the claims compactly serialized, which is how Firebase counts them, so whitespace from **Pretty-print** does not count: -- grey `29 / 1000 bytes serialized`: within the limit, +- grey `39 / 1000 bytes serialized`: within the limit, - red `1040 / 1000 bytes (Firebase will reject)`: too large; **Save** refuses with an error, - yellow `(invalid JSON — fix before saving)`. @@ -52,9 +52,10 @@ suit roles and flags rather than profile data. ## Removing all claims Saving an empty editor is refused on purpose, to avoid wiping claims by -accident. To remove everything, click **Clear all claims**. It sends an empty -`customAttributes` value, and the message reads *Saved — all custom claims -cleared.* +accident. To remove everything, click **Clear all claims**. It saves an empty +claims object (`{}`), and the message reads *Saved — all custom claims +cleared.* Loading a user whose claims are `{}` shows an empty editor and *Loaded +— user has no custom claims set.* ## When changes take effect diff --git a/docs/src/guide/custom-to-id-token.md b/docs/src/guide/custom-to-id-token.md index f0dc052..b7b43c5 100644 --- a/docs/src/guide/custom-to-id-token.md +++ b/docs/src/guide/custom-to-id-token.md @@ -5,15 +5,16 @@ Exchanges a custom token you already have for an ID token, by calling somewhere else, such as by your own backend, or one copied from the [UID -> Custom Token](uid-to-custom-token.md) tab. -**Needs:** the Web API key. No service account is required, so this tab works -in a profile that only has an API key. +**Needs:** an [app](../configuration.md#apps) with an API key. No service +account is required, so this tab works in a profile that only has an app's +key. ## Exchanging a token 1. Paste the token into **Custom token**. 2. Click **Exchange**. -![A pasted custom token exchanged for an ID token](../images/custom-to-id-result.png) +![The ID token from an exchanged custom token, with tier from the custom token next to plan and role stored on the user](../images/custom-to-id-result.png) The result shows the ID token with a **Copy** button, **Expires in**, and the decoded claims. Claims carried in the custom token, such as `tier` above, show diff --git a/docs/src/guide/reading-results.md b/docs/src/guide/reading-results.md index 4ea84a1..2bbdcf1 100644 --- a/docs/src/guide/reading-results.md +++ b/docs/src/guide/reading-results.md @@ -16,7 +16,8 @@ text in the box and copy it with Ctrl+C ## Decoded JWT claims Click **Decoded JWT claims** to expand a table of everything in the token's -payload, sorted by name. Values are selectable for copying. +payload, sorted by name. Values are selectable for copying. Scroll the tab to +reach the rest of a long table. Timestamps are shown twice, as the raw Unix time and as UTC, for example `1791308692 (2026-10-06T17:44:52+00:00)`. That applies to `iat` (issued at), diff --git a/docs/src/guide/uid-to-id-token.md b/docs/src/guide/uid-to-id-token.md index 631e859..4d465fa 100644 --- a/docs/src/guide/uid-to-id-token.md +++ b/docs/src/guide/uid-to-id-token.md @@ -4,7 +4,9 @@ Gets a Firebase ID token for the selected user in one click. The app signs a custom token locally, exchanges it with Google through `signInWithCustomToken`, and shows the resulting ID token. -**Needs:** a service account, the Web API key, and a selected user. +**Needs:** a service account, an [app](../configuration.md#apps) with an API +key, and a selected user. The exchange uses the selected app's key, so you can +check that an Android or iOS app's restricted key works. ## Generating a token diff --git a/docs/src/images/about-dialog.png b/docs/src/images/about-dialog.png index d2714f2..f865c35 100644 Binary files a/docs/src/images/about-dialog.png and b/docs/src/images/about-dialog.png differ diff --git a/docs/src/images/app-check-result.png b/docs/src/images/app-check-result.png index 0ee5c32..7379f53 100644 Binary files a/docs/src/images/app-check-result.png and b/docs/src/images/app-check-result.png differ diff --git a/docs/src/images/apps-dropdown.png b/docs/src/images/apps-dropdown.png new file mode 100644 index 0000000..d0bd5e9 Binary files /dev/null and b/docs/src/images/apps-dropdown.png differ diff --git a/docs/src/images/claims-loaded.png b/docs/src/images/claims-loaded.png index 5bbfe91..8e0f5da 100644 Binary files a/docs/src/images/claims-loaded.png and b/docs/src/images/claims-loaded.png differ diff --git a/docs/src/images/claims-over-limit.png b/docs/src/images/claims-over-limit.png index ae63595..2491e04 100644 Binary files a/docs/src/images/claims-over-limit.png and b/docs/src/images/claims-over-limit.png differ diff --git a/docs/src/images/claims-saved.png b/docs/src/images/claims-saved.png index d5e99b4..e791098 100644 Binary files a/docs/src/images/claims-saved.png and b/docs/src/images/claims-saved.png differ diff --git a/docs/src/images/custom-to-id-result.png b/docs/src/images/custom-to-id-result.png index 3809435..7145709 100644 Binary files a/docs/src/images/custom-to-id-result.png and b/docs/src/images/custom-to-id-result.png differ diff --git a/docs/src/images/first-run.png b/docs/src/images/first-run.png index b47a2e8..7ccda22 100644 Binary files a/docs/src/images/first-run.png and b/docs/src/images/first-run.png differ diff --git a/docs/src/images/lookup-not-found.png b/docs/src/images/lookup-not-found.png index 0329263..f5652e6 100644 Binary files a/docs/src/images/lookup-not-found.png and b/docs/src/images/lookup-not-found.png differ diff --git a/docs/src/images/profiles-dropdown.png b/docs/src/images/profiles-dropdown.png index 1bc705d..e960799 100644 Binary files a/docs/src/images/profiles-dropdown.png and b/docs/src/images/profiles-dropdown.png differ diff --git a/docs/src/images/quick-start.gif b/docs/src/images/quick-start.gif index b9fdfc8..81f5984 100644 Binary files a/docs/src/images/quick-start.gif and b/docs/src/images/quick-start.gif differ diff --git a/docs/src/images/service-account-loaded.png b/docs/src/images/service-account-loaded.png index 7e62b78..518f39b 100644 Binary files a/docs/src/images/service-account-loaded.png and b/docs/src/images/service-account-loaded.png differ diff --git a/docs/src/images/top-bar-android.png b/docs/src/images/top-bar-android.png new file mode 100644 index 0000000..5b85604 Binary files /dev/null and b/docs/src/images/top-bar-android.png differ diff --git a/docs/src/images/top-bar-ready.png b/docs/src/images/top-bar-ready.png index dfe35dc..65a68fe 100644 Binary files a/docs/src/images/top-bar-ready.png and b/docs/src/images/top-bar-ready.png differ diff --git a/docs/src/images/uid-to-custom-form.png b/docs/src/images/uid-to-custom-form.png index 42231b7..72fcee8 100644 Binary files a/docs/src/images/uid-to-custom-form.png and b/docs/src/images/uid-to-custom-form.png differ diff --git a/docs/src/images/uid-to-custom-result.png b/docs/src/images/uid-to-custom-result.png index 5fbf4f3..d62b8a4 100644 Binary files a/docs/src/images/uid-to-custom-result.png and b/docs/src/images/uid-to-custom-result.png differ diff --git a/docs/src/images/uid-to-id-result.png b/docs/src/images/uid-to-id-result.png index 5f4aa3f..e7e3766 100644 Binary files a/docs/src/images/uid-to-id-result.png and b/docs/src/images/uid-to-id-result.png differ diff --git a/docs/src/images/users-filtered.png b/docs/src/images/users-filtered.png index 0af6882..8549293 100644 Binary files a/docs/src/images/users-filtered.png and b/docs/src/images/users-filtered.png differ diff --git a/docs/src/images/users-loaded.png b/docs/src/images/users-loaded.png index f304ba2..85cd057 100644 Binary files a/docs/src/images/users-loaded.png and b/docs/src/images/users-loaded.png differ diff --git a/docs/src/introduction.md b/docs/src/introduction.md index 74cd6d8..335cfe1 100644 --- a/docs/src/introduction.md +++ b/docs/src/introduction.md @@ -5,7 +5,7 @@ you need while developing and testing a backend. Pick a user, click **Generate**, and you have a real ID token for that user, with its claims decoded underneath and a **Copy** button next to it. -![Loading users, picking one, and generating an ID token](images/quick-start.gif) +![Loading the project's apps, picking the Android app and a user, and generating an ID token](images/quick-start.gif) It covers five jobs, each on its own tab: @@ -20,7 +20,9 @@ It covers five jobs, each on its own tab: A [Users panel](guide/users-panel.md) on the left lists the project's users and searches them by email, phone or UID, so you rarely need to look a UID up by hand. [Profiles](profiles.md) keep the settings for several Firebase projects -side by side. +side by side, and each profile can hold the project's +[web, Android and iOS apps](configuration.md#apps), including API keys that are +restricted to one app. The app is a single native binary for Linux, Windows and macOS. It talks only to Google's endpoints, only when you click something, and never writes your @@ -29,8 +31,9 @@ private key or the tokens it mints to disk. ## Where to start 1. [Install the app](installation.md). -2. [Collect what you need from your Firebase project](firebase-setup.md): a - service-account key and the Web API key. +2. [Collect what you need from your Firebase project](firebase-setup.md): + usually just a service-account key. The app can read the project's web, + Android and iOS apps itself. 3. Follow the [Quick start](quick-start.md) to your first ID token. > **Use a development project.** A service-account key can sign in as any user diff --git a/docs/src/profiles.md b/docs/src/profiles.md index 65bd989..5833f71 100644 --- a/docs/src/profiles.md +++ b/docs/src/profiles.md @@ -1,7 +1,8 @@ # Profiles -A profile holds one project's settings: service-account path, Project ID, API -key, App ID and App Check debug token. Keep one per Firebase project, for +A profile holds one project's settings: service-account path, Project ID, and +the project's [apps](configuration.md#apps) with their API keys and App Check +debug tokens. Keep one per Firebase project, for example *Dev*, *Staging* and *Production*, and switch between them from the **Profile** dropdown. @@ -34,7 +35,7 @@ belongs to the profile shown in the dropdown. ## Saving -Profile names, service-account paths and Project IDs are always saved. API -keys, App IDs and debug tokens are saved only while **remember API key & App -ID** is ticked, and that checkbox applies to every profile, not just the active -one. See [Settings and storage](reference/settings-storage.md). +Profile names, service-account paths, Project IDs and app lists are always +saved. API keys and debug tokens are saved only while **remember API keys & +debug tokens** is ticked, and that checkbox applies to every profile, not just +the active one. See [Settings and storage](reference/settings-storage.md). diff --git a/docs/src/quick-start.md b/docs/src/quick-start.md index 036c09c..c15a3b5 100644 --- a/docs/src/quick-start.md +++ b/docs/src/quick-start.md @@ -1,9 +1,9 @@ # Quick start This walk-through goes from a fresh install to an ID token for one of your -users. You need a service-account key file and the project's Web API key; see -[Preparing your Firebase project](firebase-setup.md) if you do not have them -yet. +users. You need a service-account key file; see +[Preparing your Firebase project](firebase-setup.md) if you do not have one +yet. The app reads everything else from the project. ## 1. Open the app @@ -24,14 +24,17 @@ message confirms it; click **dismiss** to hide it. ![The service account loaded and Project ID filled in](images/service-account-loaded.png) -## 3. Enter the Web API key +## 3. Load the project's apps -Paste the Web API key into **API key**. The field is masked. The indicator -turns green and reads **ready**. +Click **Load apps**. The app lists the project's web, Android and iOS apps and +fills in each one's App ID and API key. Pick the app you want to test as from +the **App** dropdown; the indicator turns green and reads **ready**. -If you plan to use the App Check tab, paste the web app's **App ID** too. +![The App dropdown listing the project's web, Android and iOS apps](images/apps-dropdown.png) -![The top bar fully configured, with the indicator reading ready](images/top-bar-ready.png) +If the service account is not allowed to read apps, enter an app by hand +instead: **+ Add app**, choose its type, and paste its **API key** and +**App ID**. See [The top bar](configuration.md#apps). ## 4. Load your users @@ -62,9 +65,9 @@ curl -H "Authorization: Bearer " https://localhost:8080/api/me ## 6. Keep the setup for next time -The service-account path and Project ID are saved automatically. The API key -and App ID are saved only if you tick **remember API key & App ID**; without -it, you paste them again on each launch. See +The service-account path, Project ID and app list are saved automatically. +API keys are saved only if you tick **remember API keys & debug tokens**; +without it, click **Load apps** again after each launch, or paste the key. See [Settings and storage](reference/settings-storage.md) for what that writes to disk. diff --git a/docs/src/reference/endpoints.md b/docs/src/reference/endpoints.md index a2c6d41..a5e6941 100644 --- a/docs/src/reference/endpoints.md +++ b/docs/src/reference/endpoints.md @@ -8,11 +8,27 @@ browser; nothing else leaves the app. | Action in the app | Request | Authenticated with | |---|---|---| | **Generate** on *UID -> Custom Token* | None. The token is signed locally. | — | -| **Generate** on *UID -> ID Token*, **Exchange** on *Custom -> ID Token* | `POST identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken` | Web API key | +| **Generate** on *UID -> ID Token*, **Exchange** on *Custom -> ID Token* | `POST identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken` | Selected app's API key | | **Load users** / **Refresh** | `GET identitytoolkit.googleapis.com/v1/projects/{project}/accounts:batchGet` (1,000 per page, up to 5,000) | OAuth access token | | **Lookup**, **Load current** | `POST identitytoolkit.googleapis.com/v1/projects/{project}/accounts:lookup` | OAuth access token | | **Save**, **Clear all claims** | `POST identitytoolkit.googleapis.com/v1/projects/{project}/accounts:update`, then a lookup to read the result back | OAuth access token | -| **Exchange** on *App Check* | `POST firebaseappcheck.googleapis.com/v1beta/projects/{project}/apps/{app}:exchangeDebugToken` | Web API key | +| **Exchange** on *App Check* | `POST firebaseappcheck.googleapis.com/v1beta/projects/{project}/apps/{app}:exchangeDebugToken` | Selected app's API key | +| **Load apps** | `GET firebase.googleapis.com/v1beta1/projects/{project}:searchApps`, then for each app `GET …/webApps/{app}/config`, `…/androidApps/{app}/config` and `…/androidApps/{app}/sha`, or `…/iosApps/{app}/config` | OAuth access token | + +## API keys and app identity + +Requests authenticated with an API key send it as the `key` query parameter. +For an Android or iOS app, the request also names the app, so that keys +restricted to that app are accepted: + +| App type | Headers | +|---|---| +| Web | none | +| Android | `X-Android-Package: `, `X-Android-Cert: ` | +| iOS | `X-Ios-Bundle-Identifier: ` | + +A header is left out when its value is empty, and a SHA-1 that is not 40 hex +digits is not sent. ## OAuth access tokens diff --git a/docs/src/reference/settings-storage.md b/docs/src/reference/settings-storage.md index c34166b..d2bc712 100644 --- a/docs/src/reference/settings-storage.md +++ b/docs/src/reference/settings-storage.md @@ -19,17 +19,20 @@ The file is written when the app closes and periodically while it runs. | Project ID | Always | | | Active profile, last-used tab | Always | | | Window size and position | Always | | -| Web API key | Only with **remember API key & App ID** | Plaintext | -| App ID | Only with **remember API key & App ID** | Plaintext | -| App Check debug token | Only with **remember API key & App ID** | Plaintext | +| Each app's name, type and App ID | Always | | +| Android package and SHA-1, iOS bundle ID | Always | Public identifiers, included in every app build | +| Selected app | Always | | +| Each app's API key | Only with **remember API keys & debug tokens** | Plaintext | +| Each app's App Check debug token | Only with **remember API keys & debug tokens** | Plaintext | | Service-account private key | Never | Re-read from the saved path at every launch | | OAuth access tokens | Never | Held in memory, refreshed automatically | | Minted custom, ID and App Check tokens | Never | | -The **remember API key & App ID** checkbox applies to every profile at once. -When it is off, the app clears the API key, App ID and debug token from all -profiles when it starts and again every time it saves, so unticking it also -removes values that were saved earlier. +The **remember API keys & debug tokens** checkbox applies to every profile at +once. When it is off, the app clears every app's API key and debug token, in +all profiles, when it starts and again every time it saves, so unticking it +also removes values that were saved earlier. Clicking **Load apps** fetches the +keys again. If the service-account file has moved or been deleted, the app starts with no service account loaded and the status indicator shows `● not configured`. Use @@ -40,6 +43,14 @@ service account loaded and the status indicator shows `● not configured`. Use Close the app and delete `app.ron`. The next launch starts with a single empty profile named `Default`. +## Upgrading from 0.1.x + +Version 0.1.x stored one API key, App ID and debug token per profile. The first +launch of a newer version turns them into the profile's first app, named +after its type (for example *Web app*) and with its type taken from the App ID. +If **remember** was off, 0.1.x saved none of the three, so the profile starts +with one empty web app; **Load apps** fills in the project's apps. + ## Upgrading from early versions Versions before profiles existed stored a single set of fields at the top level diff --git a/docs/src/security.md b/docs/src/security.md index dd6dccb..f8100ef 100644 --- a/docs/src/security.md +++ b/docs/src/security.md @@ -12,8 +12,10 @@ version for day-to-day use. is never copied into the settings file. - **OAuth access tokens** and every **token shown on screen** live in memory only. -- The **API key, App ID and App Check debug token** are written to disk, in - plaintext, only when **remember API key & App ID** is ticked. See +- **API keys and App Check debug tokens** are written to disk, in plaintext, + only when **remember API keys & debug tokens** is ticked. App IDs, package + names, SHA-1s and bundle IDs are always saved; they are public, since every + build of your app contains them. See [Settings and storage](reference/settings-storage.md). ## Habits worth keeping diff --git a/docs/src/troubleshooting.md b/docs/src/troubleshooting.md index 82fb86d..4f9e44b 100644 --- a/docs/src/troubleshooting.md +++ b/docs/src/troubleshooting.md @@ -49,12 +49,15 @@ carries on with wgpu. | Message | Cause and fix | |---|---| -| `API key required` | Enter the Web API key in the top bar. | +| `API key required` | The selected app has no API key. Click **Load apps**, or paste the key. | | `API error (400): INVALID_CUSTOM_TOKEN : Invalid assertion format. 3 dot separated segments required.` | The pasted text is not a whole token. It usually lost characters while being copied. | | `API error (400): INVALID_CUSTOM_TOKEN …` with other text | The token has expired (custom tokens last one hour) or is malformed. Generate a new one. | | `API error (400): CREDENTIAL_MISMATCH` | The custom token was signed with a key from a different project than the API key belongs to. Check that the profile's key and API key are from the same project. | | `API error (400): API key not valid. Please pass a valid API key.` | The API key is wrong or deleted. Copy it again from **Project settings** → **General**. | -| `API error (403): … are blocked.` | The API key has restrictions that exclude the Identity Toolkit API or the App Check API. Loosen them in Google Cloud Console. | +| `API error (403): Requests from this Android client application are blocked.` (or the iOS equivalent) | The key is restricted to an Android or iOS app, but the selected app is a different type, or its **Package** or **Bundle ID** is empty. Select the matching app, or fill in its identifiers. | +| `API error (403): Requests from this Android client application dev.example.app are blocked.` | The package or SHA-1 does not match the key's restriction. Check both against the key in Google Cloud Console → **Credentials**. A SHA-1 marked *invalid* in the top bar is not sent. | +| `API error (403): Requests from this iOS client application are blocked.` | The bundle ID does not match the key's restriction. | +| `API error (403): … are blocked.` (other) | The API key has restrictions that exclude the Identity Toolkit API or the App Check API. Loosen them in Google Cloud Console. | | `Invalid claims JSON: …` / `custom claims must be a JSON object` | The optional claims box must hold a JSON object, such as `{"role":"admin"}`, or be empty. | | `Paste a custom token first` | The *Custom -> ID Token* input is empty. | @@ -76,6 +79,14 @@ carries on with wgpu. | `API error (403): …` with other text | The App Check API is not enabled for the project, or the API key's restrictions exclude it. | | `API error (404): …` | The App ID does not exist in the project named in **Project ID**. | +## Load apps + +| Message | Cause and fix | +|---|---| +| `Loading apps failed: API error (403): …` | The service account may not read Firebase apps, or the Firebase Management API is disabled. Grant **Firebase Viewer** (`roles/firebase.viewer`), or add the app by hand with **+ Add app**. | +| `No apps found in this Firebase project.` | The project has no registered apps. Add one under **Project settings** → **General** → **Your apps**. | +| `Loaded apps: … Some details are missing — …` | The list loaded, but one app's config or certificates could not be read. That app is listed without its key; fill it in by hand. | + ## Linux desktop issues **Browse… does nothing.** The file chooser goes through an XDG desktop @@ -88,6 +99,8 @@ pure Wayland session without XWayland, select the token text and copy it with ## About dialog links return 404 +![The About dialog, with its Source, Changelog and Security notes links](images/about-dialog.png) + The **Changelog** and **Security notes** links in the About dialog point at the git tag matching the app's version. A build from an untagged commit has no such tag, so the links 404. Release builds are not affected. diff --git a/src/app.rs b/src/app.rs index f2b00fb..e9961a7 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,7 +5,7 @@ use tokio::runtime::Handle; use tokio::sync::Mutex; use crate::async_task::AsyncTask; -use crate::config::PersistedConfig; +use crate::config::{PersistedConfig, RemoteApp}; use crate::firebase::oauth::AccessToken; use crate::firebase::service_account::ServiceAccount; use crate::firebase::HttpClient; @@ -68,6 +68,10 @@ pub struct SharedState { pub selected_user_label: Option, pub status_message: Option<(StatusKind, String)>, pub requested_profile_switch: Option, + /// Index into the active profile's apps, applied at the top of next frame. + pub requested_app_switch: Option, + /// In-flight "Load apps" import from the Firebase Management API. + pub apps_import: AsyncTask>>, /// In-flight service-account file picker. Async so the dialog's D-Bus round /// trip does not block the render thread. pub file_dialog: AsyncTask>, @@ -92,7 +96,12 @@ impl SharedState { } pub fn ready_for_id_token(&self) -> bool { - self.sa_loaded() && !self.config.active().api_key.trim().is_empty() + self.sa_loaded() && self.has_api_key() + } + + /// The selected app has an API key, which the ID-token and App Check tabs need. + pub fn has_api_key(&self) -> bool { + !self.config.active().active_app().api_key.trim().is_empty() } pub fn ready_for_users(&self) -> bool { @@ -147,6 +156,8 @@ impl FirebaseToolApp { selected_user_label: None, status_message: None, requested_profile_switch: None, + requested_app_switch: None, + apps_import: AsyncTask::new(), file_dialog: AsyncTask::new(), sa_autofilled_project_id: None, }; @@ -174,6 +185,9 @@ impl FirebaseToolApp { self.shared.selected_uid = None; self.shared.selected_user_label = None; self.shared.access_token = Arc::new(Mutex::new(None)); + // A late import would otherwise merge into the newly selected profile. + self.shared.apps_import.abort(); + self.shared.requested_app_switch = None; self.picker = uid_picker::UidPickerState::default(); self.tab_uid_custom = tab_uid_to_custom::TabState::default(); self.tab_uid_id = tab_uid_to_id::TabState::default(); @@ -182,9 +196,29 @@ impl FirebaseToolApp { self.tab_appcheck = tab_appcheck::TabState::default(); } + /// Select another app in the active profile. Only results that came from + /// the previous app's API key are cleared; the user list, selection and + /// service-account work are unaffected. + fn switch_app(&mut self, idx: usize) { + let profile = self.shared.config.active_mut(); + profile.active_app = idx.min(profile.apps.len().saturating_sub(1)); + let claims_input = std::mem::take(&mut self.tab_uid_id.claims_input); + self.tab_uid_id = tab_uid_to_id::TabState { + claims_input, + ..Default::default() + }; + let custom_token_input = std::mem::take(&mut self.tab_exchange.custom_token_input); + self.tab_exchange = tab_exchange::TabState { + custom_token_input, + ..Default::default() + }; + self.tab_appcheck = tab_appcheck::TabState::default(); + } + fn ensure_repaint(&self, ctx: &egui::Context) { if self.access_token_task.is_pending() || self.shared.file_dialog.is_pending() + || self.shared.apps_import.is_pending() || self.picker.has_pending() || self.tab_uid_custom.has_pending() || self.tab_uid_id.has_pending() @@ -209,6 +243,9 @@ impl eframe::App for FirebaseToolApp { if let Some(idx) = self.shared.requested_profile_switch.take() { self.switch_profile(idx); } + if let Some(idx) = self.shared.requested_app_switch.take() { + self.switch_app(idx); + } egui::TopBottomPanel::top("settings").show(ctx, |ui| { settings_panel::render(ui, &mut self.shared, &self.rt); @@ -266,25 +303,34 @@ impl eframe::App for FirebaseToolApp { }); } - egui::CentralPanel::default().show(ctx, |ui| match self.tab { - Tab::UidToCustom => { - tab_uid_to_custom::render(ui, &mut self.shared, &mut self.tab_uid_custom, &self.rt) - } - Tab::UidToId => { - tab_uid_to_id::render(ui, &mut self.shared, &mut self.tab_uid_id, &self.rt) - } - Tab::Exchange => { - tab_exchange::render(ui, &mut self.shared, &mut self.tab_exchange, &self.rt) - } - Tab::CustomClaims => tab_custom_claims::render( - ui, - &mut self.shared, - &mut self.tab_custom_claims, - &self.rt, - ), - Tab::AppCheck => { - tab_appcheck::render(ui, &mut self.shared, &mut self.tab_appcheck, &self.rt) - } + // The tab scrolls as a whole, so a long result (token, refresh token and + // a full claims table) stays reachable however tall the top bar is. + egui::CentralPanel::default().show(ctx, |ui| { + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| match self.tab { + Tab::UidToCustom => tab_uid_to_custom::render( + ui, + &mut self.shared, + &mut self.tab_uid_custom, + &self.rt, + ), + Tab::UidToId => { + tab_uid_to_id::render(ui, &mut self.shared, &mut self.tab_uid_id, &self.rt) + } + Tab::Exchange => { + tab_exchange::render(ui, &mut self.shared, &mut self.tab_exchange, &self.rt) + } + Tab::CustomClaims => tab_custom_claims::render( + ui, + &mut self.shared, + &mut self.tab_custom_claims, + &self.rt, + ), + Tab::AppCheck => { + tab_appcheck::render(ui, &mut self.shared, &mut self.tab_appcheck, &self.rt) + } + }); }); self.ensure_repaint(ctx); diff --git a/src/config.rs b/src/config.rs index 5eed1dc..e65162d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,120 @@ use serde::{Deserialize, Serialize}; +use crate::firebase::{normalize_sha1, ApiKeyAuth, ClientIdentity}; + +/// The kind of Firebase app a config describes. It decides which identifying +/// headers accompany the API key, which is what lets keys restricted to an +/// Android or iOS app be used from here. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Platform { + #[default] + Web, + Android, + Ios, +} + +impl Platform { + pub const ALL: [Platform; 3] = [Platform::Web, Platform::Android, Platform::Ios]; + + pub fn label(self) -> &'static str { + match self { + Platform::Web => "Web", + Platform::Android => "Android", + Platform::Ios => "iOS", + } + } + + /// Firebase App IDs embed the platform: `1::android:`. + /// `None` when the ID names no platform (empty, partial, or malformed). + pub fn from_app_id(app_id: &str) -> Option { + let id = app_id.trim(); + if id.contains(":android:") { + Some(Platform::Android) + } else if id.contains(":ios:") { + Some(Platform::Ios) + } else if id.contains(":web:") { + Some(Platform::Web) + } else { + None + } + } + + pub fn infer_from_app_id(app_id: &str) -> Self { + Self::from_app_id(app_id).unwrap_or_default() + } +} + +/// One Firebase app registered in a project (web, Android or iOS). +/// +/// `api_key` and `debug_token` are secrets and follow `remember_secrets`; the +/// rest identifies the app, ships inside every client build, and is always +/// persisted so an app list survives a restart. +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct AppConfig { + pub name: String, + pub platform: Platform, + pub app_id: String, + pub api_key: String, + pub debug_token: String, + pub android_package: String, + pub android_sha1: String, + pub ios_bundle_id: String, +} + +impl AppConfig { + pub fn new(platform: Platform) -> Self { + Self { + name: format!("{} app", platform.label()), + platform, + ..Default::default() + } + } + + /// Nothing but (possibly) a default name: a placeholder an import may replace. + pub fn is_blank(&self) -> bool { + self.app_id.is_empty() + && self.api_key.is_empty() + && self.debug_token.is_empty() + && self.android_package.is_empty() + && self.android_sha1.is_empty() + && self.ios_bundle_id.is_empty() + } + + pub fn display_label(&self) -> String { + let name = self.name.trim(); + let name = if name.is_empty() { + "(unnamed app)" + } else { + name + }; + format!("{name} ({})", self.platform.label()) + } + + /// The API key plus the identity headers its platform calls for. + pub fn api_auth(&self) -> ApiKeyAuth { + let identity = match self.platform { + Platform::Web => ClientIdentity::Web, + Platform::Android => ClientIdentity::Android { + package: self.android_package.trim().to_string(), + sha1: normalize_sha1(&self.android_sha1), + }, + Platform::Ios => ClientIdentity::Ios { + bundle_id: self.ios_bundle_id.trim().to_string(), + }, + }; + ApiKeyAuth { + api_key: self.api_key.trim().to_string(), + identity, + } + } + + pub fn clear_secrets(&mut self) { + self.api_key.clear(); + self.debug_token.clear(); + } +} + #[derive(Debug, Default, Clone, Serialize, Deserialize)] pub struct Profile { pub name: String, @@ -8,11 +123,154 @@ pub struct Profile { #[serde(default)] pub project_id: String, #[serde(default)] - pub api_key: String, + pub apps: Vec, #[serde(default)] + pub active_app: usize, + + // Pre-multi-app profiles held a single app's values directly. Aliased in so + // `migrate` can turn them into the profile's first app. Never re-serialized. + #[serde(default, alias = "api_key", skip_serializing)] + pub legacy_api_key: String, + #[serde(default, alias = "app_id", skip_serializing)] + pub legacy_app_id: String, + #[serde(default, alias = "debug_token", skip_serializing)] + pub legacy_debug_token: String, +} + +impl Profile { + pub fn named(name: impl Into) -> Self { + let mut p = Self { + name: name.into(), + ..Default::default() + }; + p.migrate(); + p + } + + /// Move single-app fields into `apps` and guarantee at least one app, so + /// `active_app()` can never panic. + pub fn migrate(&mut self) { + let api_key = std::mem::take(&mut self.legacy_api_key); + let app_id = std::mem::take(&mut self.legacy_app_id); + let debug_token = std::mem::take(&mut self.legacy_debug_token); + if self.apps.is_empty() + && !(api_key.is_empty() && app_id.is_empty() && debug_token.is_empty()) + { + let mut app = AppConfig::new(Platform::infer_from_app_id(&app_id)); + app.app_id = app_id; + app.api_key = api_key; + app.debug_token = debug_token; + self.apps.push(app); + } + if self.apps.is_empty() { + self.apps.push(AppConfig::new(Platform::Web)); + } + if self.active_app >= self.apps.len() { + self.active_app = 0; + } + } + + pub fn active_app(&self) -> &AppConfig { + &self.apps[self.active_app] + } + + pub fn active_app_mut(&mut self) -> &mut AppConfig { + &mut self.apps[self.active_app] + } + + pub fn has_secrets(&self) -> bool { + self.apps + .iter() + .any(|a| !a.api_key.is_empty() || !a.debug_token.is_empty()) + } +} + +/// An app as reported by the Firebase Management API, ready to merge. +#[derive(Debug, Default, Clone)] +pub struct RemoteApp { + pub platform: Platform, pub app_id: String, - #[serde(default)] - pub debug_token: String, + pub display_name: String, + pub api_key: Option, + pub android_package: String, + pub android_sha1: Option, + pub ios_bundle_id: String, + /// Set when part of the app's details could not be fetched. + pub warning: Option, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct MergeSummary { + pub added: usize, + pub updated: usize, +} + +/// Merge apps fetched from Firebase into a profile's list, matching on App ID. +/// +/// Firebase is authoritative for what identifies an app (platform, package, +/// bundle ID) and for its key when it reports one. A name or SHA-1 the user set +/// locally is kept, since they may have renamed the app or picked a different +/// signing certificate. A blank placeholder app is dropped, and the selection +/// stays on the same app. +pub fn merge_imported( + apps: &mut Vec, + active: &mut usize, + remote: Vec, +) -> MergeSummary { + let active_id = apps.get(*active).map(|a| a.app_id.clone()); + let mut summary = MergeSummary::default(); + + for r in remote { + if r.app_id.is_empty() { + continue; + } + if let Some(app) = apps.iter_mut().find(|a| a.app_id == r.app_id) { + app.platform = r.platform; + if !r.android_package.is_empty() { + app.android_package = r.android_package; + } + if !r.ios_bundle_id.is_empty() { + app.ios_bundle_id = r.ios_bundle_id; + } + if let Some(key) = r.api_key { + app.api_key = key; + } + if app.name.trim().is_empty() { + app.name = r.display_name; + } + if app.android_sha1.trim().is_empty() { + if let Some(sha) = r.android_sha1 { + app.android_sha1 = sha; + } + } + summary.updated += 1; + } else { + apps.push(AppConfig { + name: if r.display_name.trim().is_empty() { + format!("{} app", r.platform.label()) + } else { + r.display_name + }, + platform: r.platform, + app_id: r.app_id, + api_key: r.api_key.unwrap_or_default(), + debug_token: String::new(), + android_package: r.android_package, + android_sha1: r.android_sha1.unwrap_or_default(), + ios_bundle_id: r.ios_bundle_id, + }); + summary.added += 1; + } + } + + if apps.iter().any(|a| !a.is_blank()) { + apps.retain(|a| !a.is_blank()); + } + *active = active_id + .filter(|id| !id.is_empty()) + .and_then(|id| apps.iter().position(|a| a.app_id == id)) + .unwrap_or(0); + summary } #[derive(Debug, Default, Clone, Serialize, Deserialize)] @@ -44,18 +302,18 @@ pub struct PersistedConfig { impl PersistedConfig { pub fn migrate(&mut self) { if self.profiles.is_empty() { - let migrated = Profile { - name: "Default".to_string(), - service_account_path: std::mem::take(&mut self.legacy_service_account_path), - project_id: std::mem::take(&mut self.legacy_project_id), - api_key: std::mem::take(&mut self.legacy_api_key), - app_id: std::mem::take(&mut self.legacy_app_id), - debug_token: std::mem::take(&mut self.legacy_debug_token), - }; // Always insert at least one profile so .active() can never panic. // We don't special-case "empty legacy" — an empty Default profile // is also the correct first-launch state. - self.profiles.push(migrated); + self.profiles.push(Profile { + name: "Default".to_string(), + service_account_path: std::mem::take(&mut self.legacy_service_account_path), + project_id: std::mem::take(&mut self.legacy_project_id), + legacy_api_key: std::mem::take(&mut self.legacy_api_key), + legacy_app_id: std::mem::take(&mut self.legacy_app_id), + legacy_debug_token: std::mem::take(&mut self.legacy_debug_token), + ..Default::default() + }); self.active_profile = 0; } else { // Defensive: clear legacy fields so they don't drift. @@ -65,6 +323,9 @@ impl PersistedConfig { self.legacy_app_id.clear(); self.legacy_debug_token.clear(); } + for p in &mut self.profiles { + p.migrate(); + } if self.active_profile >= self.profiles.len() { self.active_profile = 0; } @@ -78,11 +339,224 @@ impl PersistedConfig { &mut self.profiles[self.active_profile] } + /// Clear API keys and debug tokens on every app of every profile. App + /// identifiers are kept: they are not secret and the app list depends on them. pub fn clear_secrets_all(&mut self) { for p in &mut self.profiles { - p.api_key.clear(); - p.app_id.clear(); - p.debug_token.clear(); + for app in &mut p.apps { + app.clear_secrets(); + } } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn load(ron_str: &str) -> PersistedConfig { + let mut c: PersistedConfig = ron::from_str(ron_str).expect("parse"); + c.migrate(); + c + } + + #[test] + fn platform_is_inferred_from_app_id() { + let infer = Platform::infer_from_app_id; + assert_eq!(infer("1:316659987175:web:ebcef886"), Platform::Web); + assert_eq!(infer("1:316659987175:android:b9522e66"), Platform::Android); + assert_eq!(infer(" 1:316659987175:ios:57dd27a1 "), Platform::Ios); + assert_eq!(infer(""), Platform::Web); + assert_eq!(infer("not-an-app-id"), Platform::Web); + assert_eq!(Platform::from_app_id("1:9:"), None); + assert_eq!(Platform::from_app_id("1:9:web:x"), Some(Platform::Web)); + } + + #[test] + fn v016_profile_migrates_into_one_app() { + // Shape written by v0.1.6, with remember_secrets on. + let c = load( + r#"(profiles:[(name:"Dev",service_account_path:"/k.json",project_id:"p", + api_key:"AIzaKEY",app_id:"1:9:android:abc",debug_token:"dbg")], + active_profile:0,remember_secrets:true,last_tab:2)"#, + ); + let p = c.active(); + assert_eq!(p.apps.len(), 1); + let app = p.active_app(); + assert_eq!(app.platform, Platform::Android); + assert_eq!(app.name, "Android app"); + assert_eq!(app.app_id, "1:9:android:abc"); + assert_eq!(app.api_key, "AIzaKEY"); + assert_eq!(app.debug_token, "dbg"); + assert_eq!(p.project_id, "p"); + } + + #[test] + fn empty_v016_profile_gets_a_blank_web_app() { + let c = load( + r#"(profiles:[(name:"Default",service_account_path:"",project_id:"",api_key:"",app_id:"",debug_token:"")],active_profile:0,remember_secrets:false,last_tab:0)"#, + ); + let p = c.active(); + assert_eq!(p.apps.len(), 1); + assert!(p.active_app().is_blank()); + assert_eq!(p.active_app().platform, Platform::Web); + } + + #[test] + fn top_level_legacy_config_migrates_through_to_an_app() { + let c = load( + r#"(service_account_path:"/k.json",project_id:"p",api_key:"AIzaKEY",app_id:"1:9:web:abc")"#, + ); + assert_eq!(c.profiles.len(), 1); + let p = c.active(); + assert_eq!(p.name, "Default"); + assert_eq!(p.service_account_path, "/k.json"); + assert_eq!(p.active_app().api_key, "AIzaKEY"); + assert_eq!(p.active_app().platform, Platform::Web); + } + + #[test] + fn legacy_fields_are_not_written_back() { + let c = load(r#"(profiles:[(name:"Dev",api_key:"AIzaKEY",app_id:"1:9:web:abc")])"#); + let out = ron::to_string(&c).unwrap(); + assert!(!out.contains("legacy"), "{out}"); + assert!(out.contains("apps:"), "{out}"); + let back = load(&out); + assert_eq!(back.active().apps.len(), 1); + assert_eq!(back.active().active_app().api_key, "AIzaKEY"); + } + + #[test] + fn active_app_index_is_clamped() { + let c = load(r#"(profiles:[(name:"Dev",apps:[(name:"a")],active_app:7)])"#); + assert_eq!(c.active().active_app, 0); + } + + #[test] + fn clearing_secrets_keeps_app_identifiers() { + let mut c = load( + r#"(profiles:[(name:"Dev",apps:[(name:"Droid",platform:Android,app_id:"1:9:android:a", + api_key:"AIzaKEY",debug_token:"dbg",android_package:"dev.x",android_sha1:"AB", + ios_bundle_id:"")])])"#, + ); + c.clear_secrets_all(); + let app = c.active().active_app(); + assert!(app.api_key.is_empty() && app.debug_token.is_empty()); + assert_eq!(app.app_id, "1:9:android:a"); + assert_eq!(app.android_package, "dev.x"); + assert_eq!(app.android_sha1, "AB"); + assert_eq!(app.name, "Droid"); + } + + fn remote(platform: Platform, id: &str) -> RemoteApp { + RemoteApp { + platform, + app_id: id.to_string(), + display_name: format!("Remote {id}"), + api_key: Some(format!("key-{id}")), + ..Default::default() + } + } + + #[test] + fn import_replaces_blank_placeholder() { + let mut apps = vec![AppConfig::new(Platform::Web)]; + let mut active = 0; + let s = merge_imported( + &mut apps, + &mut active, + vec![remote(Platform::Web, "w"), remote(Platform::Ios, "i")], + ); + assert_eq!( + s, + MergeSummary { + added: 2, + updated: 0 + } + ); + assert_eq!(apps.len(), 2); + assert_eq!(apps[0].name, "Remote w"); + assert_eq!(apps[1].platform, Platform::Ios); + assert_eq!(active, 0); + } + + #[test] + fn import_updates_by_app_id_and_keeps_local_choices() { + let mut apps = vec![ + AppConfig { + name: "My web".into(), + app_id: "w".into(), + api_key: "local-w".into(), + ..AppConfig::new(Platform::Web) + }, + AppConfig { + name: "My droid".into(), + platform: Platform::Web, // wrong locally; Firebase corrects it + app_id: "a".into(), + api_key: "local-a".into(), + android_sha1: "LOCALSHA".into(), + ..Default::default() + }, + ]; + let mut active = 1; + let mut a = remote(Platform::Android, "a"); + a.android_package = "dev.x".into(); + a.android_sha1 = Some("REMOTESHA".into()); + let mut w = remote(Platform::Web, "w"); + w.api_key = None; // config fetch failed: must not wipe the local key + let s = merge_imported(&mut apps, &mut active, vec![w, a]); + + assert_eq!( + s, + MergeSummary { + added: 0, + updated: 2 + } + ); + assert_eq!(apps.len(), 2); + assert_eq!(apps[0].api_key, "local-w"); + assert_eq!(apps[0].name, "My web"); + assert_eq!(apps[1].platform, Platform::Android); + assert_eq!(apps[1].android_package, "dev.x"); + assert_eq!(apps[1].api_key, "key-a"); + assert_eq!(apps[1].android_sha1, "LOCALSHA"); + assert_eq!(apps[1].name, "My droid"); + assert_eq!(active, 1); + } + + #[test] + fn import_keeps_selection_on_the_same_app() { + let mut apps = vec![ + AppConfig::new(Platform::Web), + AppConfig { + app_id: "x".into(), + ..AppConfig::new(Platform::Ios) + }, + ]; + let mut active = 1; + merge_imported(&mut apps, &mut active, vec![remote(Platform::Web, "w")]); + // The blank placeholder at index 0 is dropped; "x" moves to index 0. + assert_eq!(apps[active].app_id, "x"); + assert_eq!(apps.len(), 2); + } + + #[test] + fn api_auth_carries_platform_identity() { + let app = AppConfig { + platform: Platform::Android, + api_key: " AIzaKEY ".into(), + android_package: "dev.x".into(), + android_sha1: "ce:de:fc:21:eb:68:c9:1c:e9:94:7e:53:cb:2e:aa:68:a9:21:fe:ac".into(), + ..Default::default() + }; + let auth = app.api_auth(); + assert_eq!(auth.api_key, "AIzaKEY"); + assert_eq!( + auth.identity, + ClientIdentity::Android { + package: "dev.x".into(), + sha1: Some("CEDEFC21EB68C91CE9947E53CB2EAA68A921FEAC".into()), + } + ); + } +} diff --git a/src/firebase/appcheck.rs b/src/firebase/appcheck.rs index d9084d7..082a613 100644 --- a/src/firebase/appcheck.rs +++ b/src/firebase/appcheck.rs @@ -2,7 +2,7 @@ use anyhow::{anyhow, Context, Result}; use serde::Deserialize; use serde_json::json; -use super::{error_message, HttpClient, PATH_SEGMENT}; +use super::{error_message, ApiKeyAuth, HttpClient, PATH_SEGMENT}; use percent_encoding::utf8_percent_encode; #[derive(Debug, Clone, Deserialize)] @@ -17,7 +17,7 @@ pub async fn exchange_debug_token( project_id: &str, app_id: &str, debug_token: &str, - api_key: &str, + auth: &ApiKeyAuth, ) -> Result { // project_id and app_id are path segments, so they are percent-encoded // individually rather than interpolated raw. @@ -28,10 +28,8 @@ pub async fn exchange_debug_token( ); let body = json!({ "debugToken": debug_token }); - let resp = http - .0 - .post(&url) - .query(&[("key", api_key)]) + let resp = auth + .apply(http.0.post(&url)) .json(&body) .send() .await diff --git a/src/firebase/apps.rs b/src/firebase/apps.rs new file mode 100644 index 0000000..93ff656 --- /dev/null +++ b/src/firebase/apps.rs @@ -0,0 +1,338 @@ +//! Firebase Management API: list a project's apps and their client config, so +//! a profile can be filled in without copying App IDs and keys by hand. + +use anyhow::{anyhow, Context, Result}; +use base64::{engine::general_purpose::STANDARD, Engine}; +use percent_encoding::utf8_percent_encode; +use serde::Deserialize; + +use super::oauth::AccessToken; +use super::{error_message, HttpClient, PATH_SEGMENT}; +use crate::config::{Platform, RemoteApp}; + +const BASE: &str = "https://firebase.googleapis.com/v1beta1/projects"; + +#[derive(Debug, Deserialize)] +struct SearchAppsResp { + #[serde(default)] + apps: Vec, + #[serde(rename = "nextPageToken", default)] + next_page_token: Option, +} + +#[derive(Debug, Deserialize)] +struct SearchedApp { + #[serde(rename = "appId", default)] + app_id: String, + #[serde(rename = "displayName", default)] + display_name: String, + #[serde(default)] + platform: String, + /// Package name for Android, bundle ID for iOS, an opaque ID for web. + #[serde(default)] + namespace: String, +} + +#[derive(Debug, Deserialize)] +struct WebConfig { + #[serde(rename = "apiKey", default)] + api_key: Option, +} + +#[derive(Debug, Deserialize)] +struct ConfigFile { + #[serde(rename = "configFileContents", default)] + contents: String, +} + +#[derive(Debug, Deserialize)] +struct ShaList { + #[serde(default)] + certificates: Vec, +} + +#[derive(Debug, Deserialize)] +struct ShaCertificate { + #[serde(rename = "shaHash", default)] + sha_hash: String, + #[serde(rename = "certType", default)] + cert_type: String, +} + +async fn get_json(http: &HttpClient, token: &AccessToken, url: &str, what: &str) -> Result { + let resp = http + .0 + .get(url) + .bearer_auth(&token.token) + .send() + .await + .with_context(|| format!("GET {what}"))?; + let status = resp.status(); + let text = resp + .text() + .await + .with_context(|| format!("read {what} body"))?; + if !status.is_success() { + let msg = error_message(&text).unwrap_or_else(|| text.clone()); + return Err(anyhow!("API error ({}): {}", status.as_u16(), msg)); + } + Ok(text) +} + +/// List every web, Android and iOS app in the project, with its API key and +/// identifiers. A failure fetching one app's details becomes a warning on that +/// app rather than failing the whole import. +pub async fn list_apps( + http: &HttpClient, + project_id: &str, + token: &AccessToken, +) -> Result> { + let project = utf8_percent_encode(project_id, PATH_SEGMENT).to_string(); + let mut searched = Vec::new(); + let mut page: Option = None; + loop { + let mut url = reqwest::Url::parse(&format!("{BASE}/{project}:searchApps")) + .context("build searchApps URL")?; + url.query_pairs_mut().append_pair("pageSize", "100"); + if let Some(p) = &page { + url.query_pairs_mut().append_pair("pageToken", p); + } + let text = get_json(http, token, url.as_str(), "searchApps").await?; + let parsed: SearchAppsResp = serde_json::from_str(&text).context("parse searchApps")?; + searched.extend(parsed.apps); + match parsed.next_page_token { + Some(t) if !t.is_empty() => page = Some(t), + _ => break, + } + } + + let mut out = Vec::with_capacity(searched.len()); + for app in searched { + let Some(platform) = parse_platform(&app.platform) else { + continue; + }; + let mut remote = RemoteApp { + platform, + display_name: app.display_name, + ..Default::default() + }; + match platform { + Platform::Android => remote.android_package = app.namespace, + Platform::Ios => remote.ios_bundle_id = app.namespace, + Platform::Web => {} + } + if let Err(e) = fill_details(http, token, &project, &app.app_id, &mut remote).await { + remote.warning = Some(format!("{}: {e}", remote.display_name)); + } + remote.app_id = app.app_id; + out.push(remote); + } + Ok(out) +} + +async fn fill_details( + http: &HttpClient, + token: &AccessToken, + project: &str, + app_id: &str, + remote: &mut RemoteApp, +) -> Result<()> { + let id = utf8_percent_encode(app_id, PATH_SEGMENT).to_string(); + match remote.platform { + Platform::Web => { + let url = format!("{BASE}/{project}/webApps/{id}/config"); + let text = get_json(http, token, &url, "webApps config").await?; + let cfg: WebConfig = serde_json::from_str(&text).context("parse web config")?; + remote.api_key = cfg.api_key.filter(|k| !k.is_empty()); + } + Platform::Android => { + let url = format!("{BASE}/{project}/androidApps/{id}/config"); + let file = + decode_config_file(&get_json(http, token, &url, "androidApps config").await?)?; + if let Some((key, package)) = parse_google_services(&file, app_id) { + remote.api_key = key; + if !package.is_empty() { + remote.android_package = package; + } + } + let url = format!("{BASE}/{project}/androidApps/{id}/sha"); + let text = get_json(http, token, &url, "androidApps sha").await?; + let list: ShaList = serde_json::from_str(&text).context("parse sha list")?; + remote.android_sha1 = list + .certificates + .into_iter() + .find(|c| c.cert_type == "SHA_1") + .map(|c| colon_hex(&c.sha_hash)); + } + Platform::Ios => { + let url = format!("{BASE}/{project}/iosApps/{id}/config"); + let file = decode_config_file(&get_json(http, token, &url, "iosApps config").await?)?; + remote.api_key = parse_plist_string(&file, "API_KEY"); + if let Some(bundle) = parse_plist_string(&file, "BUNDLE_ID") { + remote.ios_bundle_id = bundle; + } + } + } + Ok(()) +} + +fn parse_platform(s: &str) -> Option { + match s { + "WEB" => Some(Platform::Web), + "ANDROID" => Some(Platform::Android), + "IOS" => Some(Platform::Ios), + _ => None, + } +} + +fn decode_config_file(json: &str) -> Result { + let file: ConfigFile = serde_json::from_str(json).context("parse config file response")?; + let bytes = STANDARD + .decode(file.contents.trim()) + .context("base64 decode config file")?; + String::from_utf8(bytes).context("config file is not UTF-8") +} + +/// Find this app's entry in a `google-services.json`, which can describe +/// several apps, and return its API key and package name. +fn parse_google_services(json: &str, app_id: &str) -> Option<(Option, String)> { + let v: serde_json::Value = serde_json::from_str(json).ok()?; + let client = v.get("client")?.as_array()?.iter().find(|c| { + c.pointer("/client_info/mobilesdk_app_id") + .and_then(|x| x.as_str()) + == Some(app_id) + })?; + let key = client + .get("api_key") + .and_then(|k| k.as_array()) + .and_then(|keys| keys.iter().find_map(|k| k.get("current_key")?.as_str())) + .filter(|k| !k.is_empty()) + .map(String::from); + let package = client + .pointer("/client_info/android_client_info/package_name") + .and_then(|p| p.as_str()) + .unwrap_or_default() + .to_string(); + Some((key, package)) +} + +/// Read a `` value from a `GoogleService-Info.plist`. +/// +/// Plain string matching rather than an XML parser: Google generates these +/// files in a fixed layout and the values we read (keys, bundle IDs) never +/// contain XML entities. +fn parse_plist_string(xml: &str, key: &str) -> Option { + let marker = format!("{key}"); + let rest = &xml[xml.find(&marker)? + marker.len()..]; + let start = rest.find("")? + "".len(); + // The value must be the very next element, not one further down the file. + if !rest[..start - "".len()].trim().is_empty() { + return None; + } + let end = rest[start..].find("")?; + let value = rest[start..start + end].trim(); + (!value.is_empty()).then(|| value.to_string()) +} + +/// `cedefc21…` → `CE:DE:FC:21:…`, the form the Firebase console shows. +fn colon_hex(hash: &str) -> String { + hash.as_bytes() + .chunks(2) + .map(|pair| String::from_utf8_lossy(pair).to_ascii_uppercase()) + .collect::>() + .join(":") +} + +#[cfg(test)] +mod tests { + use super::*; + + const GOOGLE_SERVICES: &str = r#"{ + "project_info": {"project_id": "demo"}, + "client": [ + {"client_info": {"mobilesdk_app_id": "1:9:android:other", + "android_client_info": {"package_name": "dev.other"}}, + "api_key": [{"current_key": "AIzaOTHER"}]}, + {"client_info": {"mobilesdk_app_id": "1:9:android:mine", + "android_client_info": {"package_name": "dev.ftt.demo"}}, + "api_key": [{"current_key": "AIzaMINE"}]} + ] + }"#; + + #[test] + fn google_services_picks_the_matching_client() { + let (key, pkg) = parse_google_services(GOOGLE_SERVICES, "1:9:android:mine").unwrap(); + assert_eq!(key.as_deref(), Some("AIzaMINE")); + assert_eq!(pkg, "dev.ftt.demo"); + assert!(parse_google_services(GOOGLE_SERVICES, "1:9:android:absent").is_none()); + assert!(parse_google_services("not json", "x").is_none()); + } + + const PLIST: &str = r#" + + + + API_KEY + AIzaIOS + GCM_SENDER_ID + 316659987175 + BUNDLE_ID + dev.ftt.demo + IS_ADS_ENABLED + + +"#; + + #[test] + fn plist_values_are_read() { + assert_eq!( + parse_plist_string(PLIST, "API_KEY").as_deref(), + Some("AIzaIOS") + ); + assert_eq!( + parse_plist_string(PLIST, "BUNDLE_ID").as_deref(), + Some("dev.ftt.demo") + ); + assert_eq!(parse_plist_string(PLIST, "MISSING"), None); + // A non-string value must not borrow the next key's string. + assert_eq!(parse_plist_string(PLIST, "IS_ADS_ENABLED"), None); + } + + #[test] + fn search_apps_response_parses() { + let parsed: SearchAppsResp = serde_json::from_str( + r#"{"apps":[{"name":"projects/p/androidApps/1:9:android:a","displayName":"Demo Android", + "platform":"ANDROID","appId":"1:9:android:a","namespace":"dev.ftt.demo", + "apiKeyId":"k","state":"ACTIVE"}],"nextPageToken":"t"}"#, + ) + .unwrap(); + assert_eq!(parsed.apps[0].app_id, "1:9:android:a"); + assert_eq!( + parse_platform(&parsed.apps[0].platform), + Some(Platform::Android) + ); + assert_eq!(parsed.next_page_token.as_deref(), Some("t")); + assert_eq!(parse_platform("WEB"), Some(Platform::Web)); + assert_eq!(parse_platform("IOS"), Some(Platform::Ios)); + assert_eq!(parse_platform("PLATFORM_UNSPECIFIED"), None); + } + + #[test] + fn config_file_is_base64_decoded() { + let body = format!( + r#"{{"configFilename":"x","configFileContents":"{}"}}"#, + STANDARD.encode("hello") + ); + assert_eq!(decode_config_file(&body).unwrap(), "hello"); + } + + #[test] + fn sha_is_shown_with_colons() { + assert_eq!(colon_hex("cedefc21"), "CE:DE:FC:21"); + let full = colon_hex("cedefc21eb68c91ce9947e53cb2eaa68a921feac"); + assert_eq!( + crate::firebase::normalize_sha1(&full).as_deref(), + Some("CEDEFC21EB68C91CE9947E53CB2EAA68A921FEAC") + ); + } +} diff --git a/src/firebase/id_token.rs b/src/firebase/id_token.rs index de1f29e..8bb7e0a 100644 --- a/src/firebase/id_token.rs +++ b/src/firebase/id_token.rs @@ -2,7 +2,7 @@ use anyhow::{anyhow, Context, Result}; use serde::Deserialize; use serde_json::json; -use super::{error_message, HttpClient}; +use super::{error_message, ApiKeyAuth, HttpClient}; #[derive(Debug, Clone, Deserialize)] pub struct IdTokenResponse { @@ -16,15 +16,13 @@ pub struct IdTokenResponse { pub async fn sign_in_with_custom_token( http: &HttpClient, - api_key: &str, + auth: &ApiKeyAuth, custom_token: &str, ) -> Result { let url = "https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken"; let body = json!({ "token": custom_token, "returnSecureToken": true }); - let resp = http - .0 - .post(url) - .query(&[("key", api_key)]) + let resp = auth + .apply(http.0.post(url)) .json(&body) .send() .await diff --git a/src/firebase/mod.rs b/src/firebase/mod.rs index b1b2691..0d51348 100644 --- a/src/firebase/mod.rs +++ b/src/firebase/mod.rs @@ -1,4 +1,5 @@ pub mod appcheck; +pub mod apps; pub mod custom_token; pub mod id_token; pub mod jwt; @@ -9,7 +10,7 @@ pub mod users; use std::sync::Arc; use percent_encoding::{AsciiSet, CONTROLS}; -use reqwest::Client; +use reqwest::{Client, RequestBuilder}; /// Characters escaped when interpolating a value into a URL *path* segment. /// @@ -62,3 +63,141 @@ pub fn error_message(body: &str) -> Option { .and_then(|v| v.get("error").and_then(|e| e.get("message")).cloned()) .and_then(|v| v.as_str().map(String::from)) } + +/// Identifies the client app an API key is being used for. +/// +/// Google rejects a key restricted to an Android or iOS app unless the request +/// names that app in these headers, the same ones the platform SDKs send. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClientIdentity { + Web, + Android { + package: String, + sha1: Option, + }, + Ios { + bundle_id: String, + }, +} + +/// A Firebase API key together with the identity of the app it belongs to. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ApiKeyAuth { + pub api_key: String, + pub identity: ClientIdentity, +} + +impl ApiKeyAuth { + /// Add the `key` query parameter and any identity headers. Empty values + /// are left out rather than sent blank. + pub fn apply(&self, rb: RequestBuilder) -> RequestBuilder { + let rb = rb.query(&[("key", self.api_key.as_str())]); + match &self.identity { + ClientIdentity::Web => rb, + ClientIdentity::Android { package, sha1 } => { + let rb = if package.is_empty() { + rb + } else { + rb.header("X-Android-Package", package) + }; + match sha1 { + Some(sha1) => rb.header("X-Android-Cert", sha1), + None => rb, + } + } + ClientIdentity::Ios { bundle_id } if !bundle_id.is_empty() => { + rb.header("X-Ios-Bundle-Identifier", bundle_id) + } + ClientIdentity::Ios { .. } => rb, + } + } +} + +/// Normalise a SHA-1 certificate fingerprint to the 40 uppercase hex digits +/// `X-Android-Cert` expects. Accepts the colon-separated form the Firebase +/// console and `keytool` print. Returns `None` for anything that is not a +/// SHA-1, including a SHA-256 fingerprint pasted by mistake. +pub fn normalize_sha1(input: &str) -> Option { + let hex: String = input + .chars() + .filter(|c| !c.is_whitespace() && *c != ':') + .collect::() + .to_ascii_uppercase(); + (hex.len() == 40 && hex.chars().all(|c| c.is_ascii_hexdigit())).then_some(hex) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn build(auth: &ApiKeyAuth) -> reqwest::Request { + auth.apply(HttpClient::new().0.post("https://example.test/v1/x")) + .build() + .expect("request builds") + } + + fn auth(identity: ClientIdentity) -> ApiKeyAuth { + ApiKeyAuth { + api_key: "AIzaKEY".into(), + identity, + } + } + + const SHA: &str = "CEDEFC21EB68C91CE9947E53CB2EAA68A921FEAC"; + + #[test] + fn web_sends_only_the_key() { + let req = build(&auth(ClientIdentity::Web)); + assert_eq!(req.url().query(), Some("key=AIzaKEY")); + assert!(req.headers().get("X-Android-Package").is_none()); + assert!(req.headers().get("X-Ios-Bundle-Identifier").is_none()); + } + + #[test] + fn android_sends_package_and_cert() { + let req = build(&auth(ClientIdentity::Android { + package: "dev.ftt.demo".into(), + sha1: Some(SHA.into()), + })); + assert_eq!(req.url().query(), Some("key=AIzaKEY")); + assert_eq!(req.headers()["X-Android-Package"], "dev.ftt.demo"); + assert_eq!(req.headers()["X-Android-Cert"], SHA); + } + + #[test] + fn android_leaves_out_missing_values() { + let req = build(&auth(ClientIdentity::Android { + package: String::new(), + sha1: None, + })); + assert!(req.headers().get("X-Android-Package").is_none()); + assert!(req.headers().get("X-Android-Cert").is_none()); + } + + #[test] + fn ios_sends_bundle_id() { + let req = build(&auth(ClientIdentity::Ios { + bundle_id: "dev.ftt.demo".into(), + })); + assert_eq!(req.headers()["X-Ios-Bundle-Identifier"], "dev.ftt.demo"); + let req = build(&auth(ClientIdentity::Ios { + bundle_id: String::new(), + })); + assert!(req.headers().get("X-Ios-Bundle-Identifier").is_none()); + } + + #[test] + fn sha1_is_normalised() { + assert_eq!( + normalize_sha1("ce:de:fc:21:eb:68:c9:1c:e9:94:7e:53:cb:2e:aa:68:a9:21:fe:ac") + .as_deref(), + Some(SHA) + ); + assert_eq!(normalize_sha1(&format!(" {SHA}\n")).as_deref(), Some(SHA)); + assert_eq!(normalize_sha1(""), None); + assert_eq!(normalize_sha1("CEDEFC21"), None); + assert_eq!(normalize_sha1(&"Z".repeat(40)), None); + // A SHA-256 fingerprint (64 hex digits) is not accepted as a SHA-1. + assert_eq!(normalize_sha1(&"A".repeat(64)), None); + } +} diff --git a/src/ui/result_view.rs b/src/ui/result_view.rs index 604e931..ee61778 100644 --- a/src/ui/result_view.rs +++ b/src/ui/result_view.rs @@ -64,6 +64,9 @@ pub fn jwt_claims(ui: &mut egui::Ui, token: &str) { egui_extras::TableBuilder::new(ui) .striped(true) .resizable(false) + // The tab itself scrolls; a second, height-capped scroll area here + // left the last claims clipped below the window. + .vscroll(false) .column(egui_extras::Column::auto().at_least(120.0)) .column(egui_extras::Column::remainder()) .header(20.0, |mut header| { diff --git a/src/ui/settings_panel.rs b/src/ui/settings_panel.rs index 7c1ed23..75807aa 100644 --- a/src/ui/settings_panel.rs +++ b/src/ui/settings_panel.rs @@ -5,8 +5,9 @@ use tokio::runtime::Handle; use crate::app::SharedState; use crate::async_task::AsyncState; -use crate::config::Profile; +use crate::config::{merge_imported, AppConfig, Platform, Profile}; use crate::firebase::service_account::ServiceAccount; +use crate::firebase::{apps, normalize_sha1, oauth}; pub fn render(ui: &mut egui::Ui, shared: &mut SharedState, rt: &Handle) { ui.add_space(4.0); @@ -88,10 +89,7 @@ pub fn render(ui: &mut egui::Ui, shared: &mut SharedState, rt: &Handle) { shared.selected_user_label = None; } - let (color, text) = match ( - shared.service_account.is_some(), - !shared.config.active().api_key.trim().is_empty(), - ) { + let (color, text) = match (shared.service_account.is_some(), shared.has_api_key()) { (true, true) => (egui::Color32::LIGHT_GREEN, "ready"), (true, false) => (egui::Color32::LIGHT_YELLOW, "SA only"), (false, _) => (egui::Color32::LIGHT_RED, "not configured"), @@ -108,27 +106,21 @@ pub fn render(ui: &mut egui::Ui, shared: &mut SharedState, rt: &Handle) { .hint_text("my-firebase-project"), ); - ui.label("API key:"); - ui.add( - egui::TextEdit::singleline(&mut shared.config.active_mut().api_key) - .desired_width(280.0) - .password(true) - .hint_text("AIzaSy…"), - ); - - ui.label("App ID:"); - ui.add( - egui::TextEdit::singleline(&mut shared.config.active_mut().app_id) - .desired_width(260.0) - .hint_text("1:1234567890:web:abc"), - ); - ui.checkbox( &mut shared.config.remember_secrets, - "remember API key & App ID", + "remember API keys & debug tokens", + ) + .on_hover_text( + "Save API keys and App Check debug tokens to disk in plaintext. \ + App names and IDs are always saved.", ); }); + ui.add_space(2.0); + app_row(ui, shared, rt); + ui.add_space(2.0); + app_fields_row(ui, shared); + if let Some(uid) = &shared.selected_uid { ui.add_space(2.0); ui.horizontal(|ui| { @@ -181,16 +173,16 @@ fn profile_row(ui: &mut egui::Ui, shared: &mut SharedState) { if ui.button("+ New").clicked() { let n = shared.config.profiles.len() + 1; - shared.config.profiles.push(Profile { - name: format!("Profile {n}"), - ..Default::default() - }); + shared + .config + .profiles + .push(Profile::named(format!("Profile {n}"))); shared.requested_profile_switch = Some(shared.config.profiles.len() - 1); } let can_delete = shared.config.profiles.len() > 1 || !shared.config.active().service_account_path.is_empty() - || !shared.config.active().api_key.is_empty(); + || shared.config.active().has_secrets(); if ui .add_enabled(can_delete, egui::Button::new("Delete")) .on_hover_text("Remove the active profile") @@ -199,10 +191,7 @@ fn profile_row(ui: &mut egui::Ui, shared: &mut SharedState) { let idx = shared.config.active_profile; shared.config.profiles.remove(idx); if shared.config.profiles.is_empty() { - shared.config.profiles.push(Profile { - name: "Default".to_string(), - ..Default::default() - }); + shared.config.profiles.push(Profile::named("Default")); } let new_idx = idx.min(shared.config.profiles.len() - 1); // Apply the index immediately as well as requesting the switch: @@ -214,3 +203,222 @@ fn profile_row(ui: &mut egui::Ui, shared: &mut SharedState) { } }); } + +/// Which app of the profile the API-key tabs use, and managing the list. +fn app_row(ui: &mut egui::Ui, shared: &mut SharedState, rt: &Handle) { + ui.horizontal_wrapped(|ui| { + ui.label("App:"); + + let profile = shared.config.active(); + let active_idx = profile.active_app; + let mut selected_idx = active_idx; + egui::ComboBox::from_id_salt("app-combo") + .selected_text(profile.active_app().display_label()) + .width(220.0) + .show_ui(ui, |ui| { + for (i, app) in profile.apps.iter().enumerate() { + ui.selectable_value(&mut selected_idx, i, app.display_label()); + } + }); + if selected_idx != active_idx { + shared.requested_app_switch = Some(selected_idx); + } + + ui.label("name:"); + ui.add( + egui::TextEdit::singleline(&mut shared.config.active_mut().active_app_mut().name) + .desired_width(140.0) + .hint_text("app name"), + ); + + ui.menu_button("+ Add app", |ui| { + for platform in Platform::ALL { + if ui.button(platform.label()).clicked() { + let profile = shared.config.active_mut(); + profile.apps.push(AppConfig::new(platform)); + shared.requested_app_switch = Some(profile.apps.len() - 1); + ui.close(); + } + } + }); + + let profile = shared.config.active(); + let can_remove = profile.apps.len() > 1 || !profile.active_app().is_blank(); + if ui + .add_enabled(can_remove, egui::Button::new("Remove")) + .on_hover_text("Remove the selected app from this profile") + .clicked() + { + let profile = shared.config.active_mut(); + let idx = profile.active_app; + profile.apps.remove(idx); + if profile.apps.is_empty() { + profile.apps.push(AppConfig::new(Platform::Web)); + } + let new_idx = idx.min(profile.apps.len() - 1); + // Same reasoning as Delete for profiles: the rest of this frame reads + // active_app(), so the index must be valid before the switch applies. + profile.active_app = new_idx; + shared.requested_app_switch = Some(new_idx); + } + + let can_import = shared.ready_for_users() && !shared.apps_import.is_pending(); + if ui + .add_enabled(can_import, egui::Button::new("Load apps")) + .on_hover_text(if shared.ready_for_users() { + "Import this project's web, Android and iOS apps with their API keys" + } else { + "Load a service account and set Project ID first" + }) + .clicked() + { + spawn_import(shared, rt); + } + if shared.apps_import.is_pending() { + ui.spinner(); + } + poll_import(shared); + }); +} + +/// The selected app's API key and identifiers: type, key and App ID on one +/// row, and for Android and iOS a second row with the identifiers sent +/// alongside the key. Two short rows rather than one wrapping row keeps every +/// label beside its field down to the 900 px minimum window width. +fn app_fields_row(ui: &mut egui::Ui, shared: &mut SharedState) { + let app = shared.config.active_mut().active_app_mut(); + let platform_before = app.platform; + + ui.horizontal(|ui| { + ui.label("Type:"); + egui::ComboBox::from_id_salt("app-platform") + .selected_text(app.platform.label()) + .width(80.0) + .show_ui(ui, |ui| { + for platform in Platform::ALL { + ui.selectable_value(&mut app.platform, platform, platform.label()); + } + }); + + ui.label("API key:"); + ui.add( + egui::TextEdit::singleline(&mut app.api_key) + .desired_width(220.0) + .password(true) + .hint_text("AIzaSy…"), + ); + + ui.label("App ID:"); + let resp = ui.add( + egui::TextEdit::singleline(&mut app.app_id) + .desired_width(250.0) + .hint_text("1:1234567890:web:abc"), + ); + // Follow the platform named in a pasted App ID, so a manually added app + // ends up sending the right identity headers. + if resp.changed() { + if let Some(platform) = Platform::from_app_id(&app.app_id) { + app.platform = platform; + } + } + }); + + match app.platform { + Platform::Web => {} + Platform::Android => { + ui.add_space(2.0); + ui.horizontal(|ui| { + ui.label("Package:"); + ui.add( + egui::TextEdit::singleline(&mut app.android_package) + .desired_width(200.0) + .hint_text("com.example.app"), + ); + ui.label("SHA-1:"); + ui.add( + egui::TextEdit::singleline(&mut app.android_sha1) + .desired_width(400.0) + .hint_text("AB:CD:…"), + ) + .on_hover_text( + "Signing certificate SHA-1. Needed only if the API key is \ + restricted to Android apps.", + ); + if !app.android_sha1.trim().is_empty() + && normalize_sha1(&app.android_sha1).is_none() + { + ui.colored_label(egui::Color32::LIGHT_YELLOW, "invalid") + .on_hover_text( + "Not a SHA-1 fingerprint (40 hex digits), so it is not sent.", + ); + } + }); + } + Platform::Ios => { + ui.add_space(2.0); + ui.horizontal(|ui| { + ui.label("Bundle ID:"); + ui.add( + egui::TextEdit::singleline(&mut app.ios_bundle_id) + .desired_width(200.0) + .hint_text("com.example.app"), + ); + }); + } + } + + // Keep a still-default name ("Web app") in step with the type, so an app + // added as Web and given an Android App ID is not left mislabelled. + if app.platform != platform_before && app.name == AppConfig::new(platform_before).name { + app.name = AppConfig::new(app.platform).name; + } +} + +fn spawn_import(shared: &mut SharedState, rt: &Handle) { + let Some(sa) = shared.service_account.clone() else { + return; + }; + let project_id = shared.config.active().project_id.trim().to_string(); + let http = shared.http.clone(); + let cached = shared.access_token.clone(); + shared.apps_import.spawn(rt, async move { + let token = oauth::ensure_access_token(&http, &sa, &cached).await?; + apps::list_apps(&http, &project_id, &token).await + }); +} + +fn poll_import(shared: &mut SharedState) { + match shared.apps_import.poll() { + AsyncState::JustCompleted(Ok(remote)) => { + if remote.is_empty() { + shared.set_info("No apps found in this Firebase project."); + return; + } + let warnings: Vec = remote.iter().filter_map(|r| r.warning.clone()).collect(); + let profile = shared.config.active_mut(); + let before = profile.active_app().app_id.clone(); + let summary = merge_imported(&mut profile.apps, &mut profile.active_app, remote); + // The index may move when a blank placeholder is dropped, but that + // is still the same app. Only a different app invalidates results. + if before.is_empty() || profile.active_app().app_id != before { + shared.requested_app_switch = Some(profile.active_app); + } + let mut msg = format!( + "Loaded apps: {} added, {} updated.", + summary.added, summary.updated + ); + if !warnings.is_empty() { + msg.push_str(&format!( + " Some details are missing — {}", + warnings.join("; ") + )); + shared.set_error(msg); + } else { + shared.set_info(msg); + } + } + AsyncState::JustCompleted(Err(e)) => shared.set_error(format!("Loading apps failed: {e}")), + AsyncState::Failed => shared.set_error("Loading apps failed unexpectedly."), + AsyncState::Pending | AsyncState::Idle => {} + } +} diff --git a/src/ui/tab_appcheck.rs b/src/ui/tab_appcheck.rs index de4acff..00df666 100644 --- a/src/ui/tab_appcheck.rs +++ b/src/ui/tab_appcheck.rs @@ -36,26 +36,26 @@ pub fn render(ui: &mut egui::Ui, shared: &mut SharedState, state: &mut TabState, ui.add_space(8.0); let project_id = shared.config.active().project_id.trim(); - let app_id = shared.config.active().app_id.trim(); - let api_key = shared.config.active().api_key.trim(); + let app = shared.config.active().active_app(); - if project_id.is_empty() || app_id.is_empty() || api_key.is_empty() { + if project_id.is_empty() || app.app_id.trim().is_empty() || app.api_key.trim().is_empty() { ui.colored_label( egui::Color32::LIGHT_YELLOW, - "Need: Project ID, App ID, and Firebase Web API key (top bar).", + "Need: Project ID, plus the selected app's App ID and API key (top bar).", ); return; } + ui.label(format!("App: {}", app.display_label())); - ui.label("Debug token (from Firebase Console → App Check → Manage debug tokens):"); + ui.label("Debug token (from Firebase Console > App Check > Manage debug tokens):"); ui.add( - egui::TextEdit::singleline(&mut shared.config.active_mut().debug_token) + egui::TextEdit::singleline(&mut shared.config.active_mut().active_app_mut().debug_token) .desired_width(f32::INFINITY) .password(true) .font(egui::TextStyle::Monospace) .hint_text("debug secret UUID"), ); - ui.weak("Saved between launches only when 'remember API key & App ID' is on (top bar)."); + ui.weak("Saved with this app, between launches only when 'remember API keys & debug tokens' is on (top bar)."); ui.add_space(6.0); ui.horizontal(|ui| { @@ -103,15 +103,16 @@ pub fn render(ui: &mut egui::Ui, shared: &mut SharedState, state: &mut TabState, fn spawn_exchange(state: &mut TabState, shared: &SharedState, rt: &Handle) { let project_id = shared.config.active().project_id.trim().to_string(); - let app_id = shared.config.active().app_id.trim().to_string(); - let api_key = shared.config.active().api_key.trim().to_string(); - let debug = shared.config.active().debug_token.trim().to_string(); + let app = shared.config.active().active_app(); + let app_id = app.app_id.trim().to_string(); + let auth = app.api_auth(); + let debug = app.debug_token.trim().to_string(); if debug.is_empty() { state.last_error = Some("Debug token required".to_string()); return; } let http = shared.http.clone(); state.task.spawn(rt, async move { - appcheck::exchange_debug_token(&http, &project_id, &app_id, &debug, &api_key).await + appcheck::exchange_debug_token(&http, &project_id, &app_id, &debug, &auth).await }); } diff --git a/src/ui/tab_custom_claims.rs b/src/ui/tab_custom_claims.rs index c8fd44f..ccaa1b9 100644 --- a/src/ui/tab_custom_claims.rs +++ b/src/ui/tab_custom_claims.rs @@ -148,7 +148,7 @@ pub fn render(ui: &mut egui::Ui, shared: &mut SharedState, state: &mut TabState, } if ui .add_enabled(can_save, egui::Button::new("Clear all claims")) - .on_hover_text("Sends an empty customAttributes string — removes every persistent claim from this user.") + .on_hover_text("Sends an empty claims object ({}) — removes every persistent claim from this user.") .clicked() { spawn_save(state, shared, &uid, true, rt); @@ -157,13 +157,7 @@ pub fn render(ui: &mut egui::Ui, shared: &mut SharedState, state: &mut TabState, match state.fetch_task.poll() { AsyncState::JustCompleted(Ok(Some(user))) => { - state.editor = match user.custom_attributes.as_deref() { - None | Some("") => String::new(), - Some(raw) => serde_json::from_str::(raw) - .ok() - .and_then(|v| serde_json::to_string_pretty(&v).ok()) - .unwrap_or_else(|| raw.to_string()), - }; + state.editor = claims_for_editor(user.custom_attributes.as_deref()); state.last_loaded_uid = Some(user.local_id); state.last_error = None; state.last_info = Some(if state.editor.is_empty() { @@ -189,15 +183,7 @@ pub fn render(ui: &mut egui::Ui, shared: &mut SharedState, state: &mut TabState, match state.save_task.poll() { AsyncState::JustCompleted(Ok(user)) => { - let raw = user.custom_attributes.as_deref().unwrap_or(""); - state.editor = if raw.is_empty() { - String::new() - } else { - serde_json::from_str::(raw) - .ok() - .and_then(|v| serde_json::to_string_pretty(&v).ok()) - .unwrap_or_else(|| raw.to_string()) - }; + state.editor = claims_for_editor(user.custom_attributes.as_deref()); state.last_loaded_uid = Some(user.local_id); state.last_error = None; state.last_info = Some(if state.editor.is_empty() { @@ -268,6 +254,11 @@ fn spawn_fetch(state: &mut TabState, shared: &SharedState, uid: &str, rt: &Handl } fn spawn_save(state: &mut TabState, shared: &SharedState, uid: &str, clear: bool, rt: &Handle) { + // Clear any message from a previous attempt, so a refusal below is not + // shown next to a stale "Saved." from the last successful save. + state.last_info = None; + state.last_error = None; + let Some(sa) = shared.service_account.clone() else { state.last_error = Some("Service account missing".to_string()); return; @@ -278,8 +269,11 @@ fn spawn_save(state: &mut TabState, shared: &SharedState, uid: &str, clear: bool return; } + // An empty object, not an empty string: accounts:update now rejects "" + // with INVALID_CLAIMS ("Not a JSON Object: null"). The Admin SDKs send + // "{}" to clear claims as well. let serialized = if clear { - String::new() + "{}".to_string() } else { let trimmed = state.editor.trim(); if trimmed.is_empty() { @@ -313,11 +307,46 @@ fn spawn_save(state: &mut TabState, shared: &SharedState, uid: &str, clear: bool let http = shared.http.clone(); let cached = shared.access_token.clone(); let uid = uid.to_string(); - state.last_info = None; - state.last_error = None; state.save_task.spawn(rt, async move { let tok = ensure_access_token(&http, &sa, &cached).await?; set_custom_attributes(&http, &project_id, &tok, &uid, &serialized).await }); } + +/// Format stored `customAttributes` for the editor. No claims (absent, empty, +/// or the `{}` that clearing leaves behind) becomes an empty editor. +fn claims_for_editor(raw: Option<&str>) -> String { + let raw = raw.unwrap_or("").trim(); + if raw.is_empty() { + return String::new(); + } + match serde_json::from_str::(raw) { + Ok(Value::Object(map)) if map.is_empty() => String::new(), + Ok(v) => serde_json::to_string_pretty(&v).unwrap_or_else(|_| raw.to_string()), + Err(_) => raw.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::claims_for_editor; + + #[test] + fn no_claims_shows_an_empty_editor() { + assert_eq!(claims_for_editor(None), ""); + assert_eq!(claims_for_editor(Some("")), ""); + assert_eq!(claims_for_editor(Some("{}")), ""); + assert_eq!(claims_for_editor(Some(" { } ")), ""); + } + + #[test] + fn claims_are_pretty_printed() { + assert_eq!( + claims_for_editor(Some(r#"{"role":"admin"}"#)), + "{\n \"role\": \"admin\"\n}" + ); + // Unparseable input is shown as-is rather than dropped. + assert_eq!(claims_for_editor(Some("not json")), "not json"); + } +} diff --git a/src/ui/tab_exchange.rs b/src/ui/tab_exchange.rs index beb3ff2..8d39b21 100644 --- a/src/ui/tab_exchange.rs +++ b/src/ui/tab_exchange.rs @@ -37,10 +37,10 @@ pub fn render(ui: &mut egui::Ui, shared: &mut SharedState, state: &mut TabState, ui.label("Calls identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken."); ui.add_space(8.0); - if shared.config.active().api_key.trim().is_empty() { + if !shared.has_api_key() { ui.colored_label( egui::Color32::LIGHT_YELLOW, - "Set the Firebase Web API key in the top bar.", + "Set an API key for the selected app in the top bar.", ); return; } @@ -97,7 +97,7 @@ pub fn render(ui: &mut egui::Ui, shared: &mut SharedState, state: &mut TabState, } fn spawn_exchange(state: &mut TabState, shared: &SharedState, rt: &Handle) { - let api_key = shared.config.active().api_key.trim().to_string(); + let auth = shared.config.active().active_app().api_auth(); let token = state.custom_token_input.trim().to_string(); if token.is_empty() { state.last_error = Some("Paste a custom token first".to_string()); @@ -105,6 +105,6 @@ fn spawn_exchange(state: &mut TabState, shared: &SharedState, rt: &Handle) { } let http = shared.http.clone(); state.task.spawn(rt, async move { - id_token::sign_in_with_custom_token(&http, &api_key, &token).await + id_token::sign_in_with_custom_token(&http, &auth, &token).await }); } diff --git a/src/ui/tab_uid_to_id.rs b/src/ui/tab_uid_to_id.rs index 54963ac..c42e311 100644 --- a/src/ui/tab_uid_to_id.rs +++ b/src/ui/tab_uid_to_id.rs @@ -47,7 +47,7 @@ pub fn render(ui: &mut egui::Ui, shared: &mut SharedState, state: &mut TabState, if !shared.ready_for_id_token() { ui.colored_label( egui::Color32::LIGHT_YELLOW, - "Need: service account + Firebase Web API key.", + "Need: service account + an API key for the selected app.", ); return; } @@ -142,8 +142,8 @@ fn spawn_generate(state: &mut TabState, shared: &SharedState, uid: &str, rt: &Ha state.last_error = Some("Service account missing".to_string()); return; }; - let api_key = shared.config.active().api_key.trim().to_string(); - if api_key.is_empty() { + let auth = shared.config.active().active_app().api_auth(); + if auth.api_key.is_empty() { state.last_error = Some("API key required".to_string()); return; } @@ -166,7 +166,7 @@ fn spawn_generate(state: &mut TabState, shared: &SharedState, uid: &str, rt: &Ha state.task.spawn(rt, async move { let custom = custom_token::create(&uid, &sa, claims)?; - let resp = id_token::sign_in_with_custom_token(&http, &api_key, &custom).await?; + let resp = id_token::sign_in_with_custom_token(&http, &auth, &custom).await?; Ok(Output { id_token: resp.id_token, refresh_token: resp.refresh_token,