@frockbot/plugin-auth 0.3.14 → 0.3.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-auth",
3
- "version": "0.3.14",
3
+ "version": "0.3.16",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,14 +21,15 @@
21
21
  },
22
22
  "dependencies": {
23
23
  "@better-auth/electron": "1.7.2",
24
- "@frockbot/client-core": "0.3.14",
25
- "@frockbot/kernel-contracts": "0.3.14",
24
+ "@capacitor/core": "8.5.0",
25
+ "@frockbot/client-core": "0.3.16",
26
+ "@frockbot/kernel-contracts": "0.3.16",
26
27
  "better-auth": "1.7.2",
27
28
  "cordis": "4.0.0-rc.8",
28
29
  "vue": "3.5.41"
29
30
  },
30
31
  "devDependencies": {
31
- "@frockbot/plugin-testkit": "0.3.14",
32
+ "@frockbot/plugin-testkit": "0.3.16",
32
33
  "@types/bun": "1.4.0",
33
34
  "@types/node": "26.2.0",
34
35
  "typescript": "npm:typescript-native-bridge@6.0.3-bridge.16.tsgo.7.0.2",
@@ -3,6 +3,10 @@ import { authSessionClientKey } from "../shared.js";
3
3
  import { computed, inject, onBeforeUnmount, onMounted, ref } from "vue";
4
4
  import { hostedAuthClient } from "./browser.js";
5
5
  import { developmentLoginUrl, isLoopbackHost } from "./development-login";
6
+ import {
7
+ isAndroidNativeShell,
8
+ requestNativeGoogleCredential,
9
+ } from "./native-google.js";
6
10
 
7
11
  const providedSession = inject(authSessionClientKey);
8
12
  if (!providedSession) throw new Error("auth session client was not provided");
@@ -17,6 +21,7 @@ const user = computed(() =>
17
21
  );
18
22
  const loading = computed(() => session.projection.value.status === "loading");
19
23
  const isDesktop = computed(() => Boolean(window.frockbotDesktop));
24
+ const isAndroid = isAndroidNativeShell();
20
25
  const isLocalDevelopment = computed(() =>
21
26
  isLoopbackHost(window.location.hostname),
22
27
  );
@@ -77,6 +82,23 @@ async function signIn(): Promise<void> {
77
82
  await window.requestAuth();
78
83
  return;
79
84
  }
85
+ if (isAndroid) {
86
+ const credential = await requestNativeGoogleCredential();
87
+ const result = await hostedAuthClient.signIn.social({
88
+ provider: "google",
89
+ idToken: {
90
+ token: credential.idToken,
91
+ nonce: credential.nonce,
92
+ },
93
+ });
94
+ if (result.error) {
95
+ throw new Error(
96
+ "FrockBot could not verify the Google sign-in. Please try again.",
97
+ );
98
+ }
99
+ window.location.reload();
100
+ return;
101
+ }
80
102
  const query = electronAuthQuery();
81
103
  const callbackURL = new URL("/", window.location.origin);
82
104
  if (query) {
@@ -138,7 +160,13 @@ onBeforeUnmount(() => {
138
160
  <div class="auth-mark" aria-hidden="true">⌁</div>
139
161
  <p class="auth-eyebrow">FrockBot</p>
140
162
  <h1 id="auth-title">Welcome back</h1>
141
- <p class="auth-copy">Sign in with your browser to continue.</p>
163
+ <p class="auth-copy">
164
+ {{
165
+ isAndroid
166
+ ? "Sign in to continue."
167
+ : "Sign in with your browser to continue."
168
+ }}
169
+ </p>
142
170
  <div v-if="loading" class="auth-loading" aria-live="polite">
143
171
  Checking your session…
144
172
  </div>
@@ -159,7 +187,13 @@ onBeforeUnmount(() => {
159
187
  @click="signIn"
160
188
  >
161
189
  <span class="google-g" aria-hidden="true">G</span>
162
- {{ signingIn ? "Waiting for browser…" : "Continue with Google" }}
190
+ {{
191
+ signingIn
192
+ ? isAndroid
193
+ ? "Signing in…"
194
+ : "Waiting for browser…"
195
+ : "Continue with Google"
196
+ }}
163
197
  </button>
164
198
  </div>
165
199
  <p v-if="error" class="auth-error" role="alert">{{ error }}</p>
@@ -0,0 +1,48 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ isAndroidNativeShell,
4
+ requestNativeGoogleCredential,
5
+ } from "./native-google.ts";
6
+
7
+ describe("native Google sign-in", () => {
8
+ test("detects only the native Android bridge", () => {
9
+ expect(
10
+ isAndroidNativeShell({
11
+ isNativePlatform: () => true,
12
+ getPlatform: () => "android",
13
+ }),
14
+ ).toBe(true);
15
+ expect(
16
+ isAndroidNativeShell({
17
+ isNativePlatform: () => true,
18
+ getPlatform: () => "ios",
19
+ }),
20
+ ).toBe(false);
21
+ expect(
22
+ isAndroidNativeShell({
23
+ isNativePlatform: () => false,
24
+ getPlatform: () => "web",
25
+ }),
26
+ ).toBe(false);
27
+ });
28
+
29
+ test("decodes the bounded native credential", async () => {
30
+ await expect(
31
+ requestNativeGoogleCredential({
32
+ signIn: () =>
33
+ Promise.resolve({ idToken: "header.payload.signature", nonce: "n" }),
34
+ }),
35
+ ).resolves.toEqual({
36
+ idToken: "header.payload.signature",
37
+ nonce: "n",
38
+ });
39
+ });
40
+
41
+ test("rejects malformed native responses", async () => {
42
+ await expect(
43
+ requestNativeGoogleCredential({
44
+ signIn: () => Promise.resolve({ idToken: "token" }),
45
+ }),
46
+ ).rejects.toThrow("Google returned an invalid sign-in response.");
47
+ });
48
+ });
@@ -0,0 +1,52 @@
1
+ import { Capacitor, registerPlugin } from "@capacitor/core";
2
+
3
+ const MAX_ID_TOKEN_LENGTH = 16_384;
4
+ const MAX_NONCE_LENGTH = 512;
5
+
6
+ interface NativePlatform {
7
+ isNativePlatform(): boolean;
8
+ getPlatform(): string;
9
+ }
10
+
11
+ export interface NativeGoogleCredential {
12
+ idToken: string;
13
+ nonce: string;
14
+ }
15
+
16
+ export interface NativeGoogleAuthPlugin {
17
+ signIn(): Promise<unknown>;
18
+ }
19
+
20
+ const nativeGoogleAuth =
21
+ registerPlugin<NativeGoogleAuthPlugin>("FrockBotGoogleAuth");
22
+
23
+ export function isAndroidNativeShell(
24
+ platform: NativePlatform = Capacitor,
25
+ ): boolean {
26
+ return platform.isNativePlatform() && platform.getPlatform() === "android";
27
+ }
28
+
29
+ export async function requestNativeGoogleCredential(
30
+ plugin: NativeGoogleAuthPlugin = nativeGoogleAuth,
31
+ ): Promise<NativeGoogleCredential> {
32
+ const result = await plugin.signIn();
33
+ if (typeof result !== "object" || result === null || Array.isArray(result)) {
34
+ throw new Error("Google returned an invalid sign-in response.");
35
+ }
36
+ const keys = Object.keys(result);
37
+ const value = result as Record<string, unknown>;
38
+ if (
39
+ keys.length !== 2 ||
40
+ !keys.includes("idToken") ||
41
+ !keys.includes("nonce") ||
42
+ typeof value.idToken !== "string" ||
43
+ !value.idToken ||
44
+ value.idToken.length > MAX_ID_TOKEN_LENGTH ||
45
+ typeof value.nonce !== "string" ||
46
+ !value.nonce ||
47
+ value.nonce.length > MAX_NONCE_LENGTH
48
+ ) {
49
+ throw new Error("Google returned an invalid sign-in response.");
50
+ }
51
+ return { idToken: value.idToken, nonce: value.nonce };
52
+ }