@frockbot/plugin-auth 0.0.0 → 0.1.1
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/frockbot.json +21 -0
- package/package.json +37 -6
- package/src/client/AuthGate.vue +174 -0
- package/src/client/browser.ts +70 -0
- package/src/client/development-login.ts +24 -0
- package/src/client/hosted-session.test.ts +185 -0
- package/src/client/hosted-session.ts +157 -0
- package/src/client/index.ts +15 -0
- package/src/client/session.test.ts +123 -0
- package/src/client/session.ts +67 -0
- package/src/client/styles.css +171 -0
- package/src/client.test.ts +12 -0
- package/src/desktop.test.ts +40 -0
- package/src/desktop.ts +21 -0
- package/src/development-login.test.ts +37 -0
- package/src/env.d.ts +43 -0
- package/src/index.ts +2 -0
- package/src/manifest.ts +3 -0
- package/src/shared.ts +122 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
package/frockbot.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 3,
|
|
3
|
+
"id": "auth",
|
|
4
|
+
"displayName": "Authentication",
|
|
5
|
+
"version": "0.0.1",
|
|
6
|
+
"compatibility": { "frockbot": ">=0.0.1" },
|
|
7
|
+
"dependencies": { "ui-theme": ">=0.0.1" },
|
|
8
|
+
"contributions": {
|
|
9
|
+
"client": {
|
|
10
|
+
"entry": "./client",
|
|
11
|
+
"mounts": [{ "slot": "root", "order": 20000 }],
|
|
12
|
+
"outlets": ["authenticated-root"]
|
|
13
|
+
},
|
|
14
|
+
"desktop": {
|
|
15
|
+
"entry": "./desktop",
|
|
16
|
+
"execution": "trusted-main",
|
|
17
|
+
"commands": []
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"permissions": ["identity:authenticate"]
|
|
21
|
+
}
|
package/package.json
CHANGED
|
@@ -1,14 +1,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-auth",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts",
|
|
8
|
+
"./client": "./src/client/index.ts",
|
|
9
|
+
"./desktop": "./src/desktop.ts",
|
|
10
|
+
"./shared": "./src/shared.ts",
|
|
11
|
+
"./manifest": "./src/manifest.ts",
|
|
12
|
+
"./frockbot.json": "./frockbot.json",
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
15
|
+
"frockbot": {
|
|
16
|
+
"manifest": "./frockbot.json"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "bun test src",
|
|
20
|
+
"typecheck": "vue-tsc --noEmit -p tsconfig.json"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@better-auth/electron": "1.7.2",
|
|
24
|
+
"@frockbot/client-core": "0.1.1",
|
|
25
|
+
"better-auth": "1.7.2",
|
|
26
|
+
"cordis": "4.0.0-rc.8",
|
|
27
|
+
"vue": "3.5.41"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@frockbot/plugin-testkit": "0.1.1",
|
|
31
|
+
"@types/bun": "1.4.0",
|
|
32
|
+
"@types/node": "26.2.0",
|
|
33
|
+
"typescript": "5.9.3",
|
|
34
|
+
"vite": "8.2.2",
|
|
35
|
+
"vue-tsc": "3.3.10"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
6
40
|
"repository": {
|
|
7
41
|
"type": "git",
|
|
8
42
|
"url": "git+https://github.com/timoconnellaus/frockbot.git",
|
|
9
43
|
"directory": "packages/plugin-auth"
|
|
10
|
-
},
|
|
11
|
-
"publishConfig": {
|
|
12
|
-
"access": "public"
|
|
13
44
|
}
|
|
14
45
|
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { authSessionClientKey } from "../shared.js";
|
|
3
|
+
import { computed, inject, onBeforeUnmount, onMounted, ref } from "vue";
|
|
4
|
+
import { hostedAuthClient } from "./browser.js";
|
|
5
|
+
import { developmentLoginUrl, isLoopbackHost } from "./development-login";
|
|
6
|
+
|
|
7
|
+
const providedSession = inject(authSessionClientKey);
|
|
8
|
+
if (!providedSession) throw new Error("auth session client was not provided");
|
|
9
|
+
const session = providedSession;
|
|
10
|
+
|
|
11
|
+
const signingIn = ref(false);
|
|
12
|
+
const error = ref<string>();
|
|
13
|
+
const user = computed(() =>
|
|
14
|
+
session.projection.value.status === "authenticated"
|
|
15
|
+
? session.projection.value.user
|
|
16
|
+
: null,
|
|
17
|
+
);
|
|
18
|
+
const loading = computed(() => session.projection.value.status === "loading");
|
|
19
|
+
const isDesktop = computed(() => Boolean(window.frockbotDesktop));
|
|
20
|
+
const isLocalDevelopment = computed(() =>
|
|
21
|
+
isLoopbackHost(window.location.hostname),
|
|
22
|
+
);
|
|
23
|
+
const unsubscribers: Array<() => void> = [];
|
|
24
|
+
let electronRedirectTimer: ReturnType<
|
|
25
|
+
typeof hostedAuthClient.ensureElectronRedirect
|
|
26
|
+
> | null = null;
|
|
27
|
+
|
|
28
|
+
function electronAuthQuery(): Record<string, string> | null {
|
|
29
|
+
const params = new URLSearchParams(window.location.search);
|
|
30
|
+
const clientId = params.get("client_id");
|
|
31
|
+
const state = params.get("state");
|
|
32
|
+
const codeChallenge = params.get("code_challenge");
|
|
33
|
+
return clientId && state && codeChallenge
|
|
34
|
+
? {
|
|
35
|
+
client_id: clientId,
|
|
36
|
+
state,
|
|
37
|
+
code_challenge: codeChallenge,
|
|
38
|
+
}
|
|
39
|
+
: null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function refreshSession(): Promise<void> {
|
|
43
|
+
try {
|
|
44
|
+
await session.refresh();
|
|
45
|
+
error.value = undefined;
|
|
46
|
+
} catch (cause) {
|
|
47
|
+
error.value =
|
|
48
|
+
cause instanceof Error ? cause.message : "Could not check your session";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function loadUser(): Promise<void> {
|
|
53
|
+
await session.refresh();
|
|
54
|
+
const query = electronAuthQuery();
|
|
55
|
+
if (
|
|
56
|
+
query &&
|
|
57
|
+
session.projection.value.status === "authenticated" &&
|
|
58
|
+
session.projection.value.mode === "better-auth"
|
|
59
|
+
) {
|
|
60
|
+
signingIn.value = true;
|
|
61
|
+
const transfer = await hostedAuthClient.electron.transferUser({
|
|
62
|
+
fetchOptions: { query },
|
|
63
|
+
});
|
|
64
|
+
if (transfer.error) throw new Error(transfer.error.message);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function signInForDevelopment(): void {
|
|
69
|
+
window.location.assign(developmentLoginUrl(new URL(window.location.href)));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function signIn(): Promise<void> {
|
|
73
|
+
signingIn.value = true;
|
|
74
|
+
error.value = undefined;
|
|
75
|
+
try {
|
|
76
|
+
if (isDesktop.value) {
|
|
77
|
+
await window.requestAuth();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const query = electronAuthQuery();
|
|
81
|
+
const callbackURL = new URL("/", window.location.origin);
|
|
82
|
+
if (query) {
|
|
83
|
+
for (const [key, value] of Object.entries(query)) {
|
|
84
|
+
callbackURL.searchParams.set(key, value);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const callback = callbackURL.toString();
|
|
88
|
+
const result = await hostedAuthClient.signIn.social({
|
|
89
|
+
provider: "google",
|
|
90
|
+
callbackURL: callback,
|
|
91
|
+
newUserCallbackURL: callback,
|
|
92
|
+
errorCallbackURL: callback,
|
|
93
|
+
fetchOptions: query ? { query } : undefined,
|
|
94
|
+
});
|
|
95
|
+
if (result.error) throw new Error(result.error.message);
|
|
96
|
+
} catch (cause) {
|
|
97
|
+
error.value = cause instanceof Error ? cause.message : "Sign-in failed";
|
|
98
|
+
signingIn.value = false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
onMounted(async () => {
|
|
103
|
+
try {
|
|
104
|
+
if (isDesktop.value) {
|
|
105
|
+
unsubscribers.push(
|
|
106
|
+
window.onAuthenticated(() => {
|
|
107
|
+
signingIn.value = false;
|
|
108
|
+
void refreshSession();
|
|
109
|
+
}),
|
|
110
|
+
window.onUserUpdated(() => {
|
|
111
|
+
void refreshSession();
|
|
112
|
+
}),
|
|
113
|
+
window.onAuthError((context) => {
|
|
114
|
+
error.value = context.message ?? "Sign-in failed";
|
|
115
|
+
signingIn.value = false;
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
} else if (electronAuthQuery()) {
|
|
119
|
+
electronRedirectTimer = hostedAuthClient.ensureElectronRedirect();
|
|
120
|
+
}
|
|
121
|
+
await loadUser();
|
|
122
|
+
} catch (cause) {
|
|
123
|
+
error.value =
|
|
124
|
+
cause instanceof Error ? cause.message : "Could not check your session";
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
onBeforeUnmount(() => {
|
|
129
|
+
for (const unsubscribe of unsubscribers) unsubscribe();
|
|
130
|
+
if (electronRedirectTimer) clearTimeout(electronRedirectTimer);
|
|
131
|
+
});
|
|
132
|
+
</script>
|
|
133
|
+
|
|
134
|
+
<template>
|
|
135
|
+
<k-slot v-if="user" name="authenticated-root" />
|
|
136
|
+
<main v-else class="auth-screen">
|
|
137
|
+
<section class="auth-card" aria-labelledby="auth-title">
|
|
138
|
+
<div class="auth-mark" aria-hidden="true">⌁</div>
|
|
139
|
+
<p class="auth-eyebrow">FrockBot</p>
|
|
140
|
+
<h1 id="auth-title">Welcome back</h1>
|
|
141
|
+
<p class="auth-copy">
|
|
142
|
+
Sign in in your browser to keep credentials out of the desktop renderer.
|
|
143
|
+
</p>
|
|
144
|
+
<div v-if="loading" class="auth-loading" aria-live="polite">
|
|
145
|
+
Checking your session…
|
|
146
|
+
</div>
|
|
147
|
+
<div v-else class="auth-actions">
|
|
148
|
+
<button
|
|
149
|
+
v-if="isLocalDevelopment"
|
|
150
|
+
class="dev-button"
|
|
151
|
+
type="button"
|
|
152
|
+
:disabled="signingIn"
|
|
153
|
+
@click="signInForDevelopment"
|
|
154
|
+
>
|
|
155
|
+
Continue as local developer
|
|
156
|
+
</button>
|
|
157
|
+
<button
|
|
158
|
+
class="google-button"
|
|
159
|
+
type="button"
|
|
160
|
+
:disabled="signingIn"
|
|
161
|
+
@click="signIn"
|
|
162
|
+
>
|
|
163
|
+
<span class="google-g" aria-hidden="true">G</span>
|
|
164
|
+
{{ signingIn ? "Waiting for browser…" : "Continue with Google" }}
|
|
165
|
+
</button>
|
|
166
|
+
</div>
|
|
167
|
+
<p v-if="error" class="auth-error" role="alert">{{ error }}</p>
|
|
168
|
+
<p v-if="isDesktop && signingIn" class="auth-hint">
|
|
169
|
+
Finish signing in in the browser. You can safely return to FrockBot when
|
|
170
|
+
it closes.
|
|
171
|
+
</p>
|
|
172
|
+
</section>
|
|
173
|
+
</main>
|
|
174
|
+
</template>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { electronProxyClient } from "@better-auth/electron/proxy";
|
|
2
|
+
import { createAuthClient } from "better-auth/client";
|
|
3
|
+
import { createHostedAuthAdapter } from "./hosted-session.js";
|
|
4
|
+
import { createAuthSessionClient } from "./session.js";
|
|
5
|
+
|
|
6
|
+
export const hostedAuthClient = createAuthClient({
|
|
7
|
+
plugins: [
|
|
8
|
+
electronProxyClient({
|
|
9
|
+
clientID: "frockbot-desktop",
|
|
10
|
+
protocol: { scheme: "com.frockbot.desktop" },
|
|
11
|
+
}),
|
|
12
|
+
],
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
function errorProjection(error: { message?: string } | null | undefined) {
|
|
16
|
+
return error ? { message: error.message } : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function embeddedAuthMode(): "anonymous" | "better-auth" | "development" {
|
|
20
|
+
const mode = document.body.dataset.frockbotAuthMode;
|
|
21
|
+
if (
|
|
22
|
+
mode !== "anonymous" &&
|
|
23
|
+
mode !== "better-auth" &&
|
|
24
|
+
mode !== "development"
|
|
25
|
+
) {
|
|
26
|
+
throw new Error("Hosted auth mode is invalid");
|
|
27
|
+
}
|
|
28
|
+
return mode;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function embeddedIsAdmin(): boolean {
|
|
32
|
+
const value = document.body.dataset.frockbotIsAdmin;
|
|
33
|
+
if (value !== "true" && value !== "false") {
|
|
34
|
+
throw new Error("Hosted admin projection is invalid");
|
|
35
|
+
}
|
|
36
|
+
return value === "true";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createBrowserAuthSessionClient() {
|
|
40
|
+
const desktop = window.frockbotDesktop
|
|
41
|
+
? {
|
|
42
|
+
getUser: () => window.getUser(),
|
|
43
|
+
signOut: () => window.signOut(),
|
|
44
|
+
}
|
|
45
|
+
: undefined;
|
|
46
|
+
const adapter = createHostedAuthAdapter({
|
|
47
|
+
location: new URL(window.location.href),
|
|
48
|
+
embeddedUserId: document.body.dataset.frockbotUserId,
|
|
49
|
+
embeddedMode: embeddedAuthMode(),
|
|
50
|
+
embeddedIsAdmin: embeddedIsAdmin(),
|
|
51
|
+
desktop,
|
|
52
|
+
betterAuth: {
|
|
53
|
+
getSession: async () => {
|
|
54
|
+
const result = await hostedAuthClient.getSession();
|
|
55
|
+
return {
|
|
56
|
+
data: result.data ? { user: result.data.user } : null,
|
|
57
|
+
error: errorProjection(result.error),
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
signOut: async () => {
|
|
61
|
+
const result = await hostedAuthClient.signOut();
|
|
62
|
+
return {
|
|
63
|
+
data: result.data,
|
|
64
|
+
error: errorProjection(result.error),
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
return createAuthSessionClient(adapter);
|
|
70
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
|
|
2
|
+
|
|
3
|
+
export const DEVELOPMENT_USER_ID = "development";
|
|
4
|
+
|
|
5
|
+
export function isLoopbackHost(hostname: string): boolean {
|
|
6
|
+
return LOOPBACK_HOSTS.has(hostname.toLowerCase());
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function developmentUserFromUrl(url: URL): string | undefined {
|
|
10
|
+
if (!isLoopbackHost(url.hostname)) return undefined;
|
|
11
|
+
return url.searchParams.get("as_user") === DEVELOPMENT_USER_ID
|
|
12
|
+
? DEVELOPMENT_USER_ID
|
|
13
|
+
: undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function developmentLoginUrl(url: URL): string {
|
|
17
|
+
try {
|
|
18
|
+
const loginUrl = new URL(url.href);
|
|
19
|
+
loginUrl.searchParams.set("as_user", DEVELOPMENT_USER_ID);
|
|
20
|
+
return loginUrl.toString();
|
|
21
|
+
} catch {
|
|
22
|
+
return url.toString();
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createHostedAuthAdapter } from "./hosted-session.js";
|
|
3
|
+
|
|
4
|
+
const user = { id: "alice", name: "Alice", email: "alice@example.com" };
|
|
5
|
+
|
|
6
|
+
function browser(
|
|
7
|
+
overrides: Partial<Parameters<typeof createHostedAuthAdapter>[0]> = {},
|
|
8
|
+
) {
|
|
9
|
+
return createHostedAuthAdapter({
|
|
10
|
+
location: new URL("https://frockbot.test/"),
|
|
11
|
+
embeddedUserId: "alice",
|
|
12
|
+
embeddedMode: "better-auth",
|
|
13
|
+
embeddedIsAdmin: true,
|
|
14
|
+
betterAuth: {
|
|
15
|
+
getSession: () => Promise.resolve({ data: { user }, error: null }),
|
|
16
|
+
signOut: () => Promise.resolve({ data: { success: true }, error: null }),
|
|
17
|
+
},
|
|
18
|
+
...overrides,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("hosted auth adapter", () => {
|
|
23
|
+
test("projects Better Auth and returns authoritative anonymous sign-out", async () => {
|
|
24
|
+
let signOuts = 0;
|
|
25
|
+
const adapter = browser({
|
|
26
|
+
betterAuth: {
|
|
27
|
+
getSession: () => Promise.resolve({ data: { user }, error: null }),
|
|
28
|
+
signOut: () => {
|
|
29
|
+
signOuts += 1;
|
|
30
|
+
return Promise.resolve({ data: { success: true }, error: null });
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
expect(await adapter.read()).toEqual({
|
|
36
|
+
schemaVersion: 1,
|
|
37
|
+
status: "authenticated",
|
|
38
|
+
mode: "better-auth",
|
|
39
|
+
user: { ...user, isAdmin: true },
|
|
40
|
+
});
|
|
41
|
+
expect(await adapter.signOut()).toEqual({
|
|
42
|
+
schemaVersion: 1,
|
|
43
|
+
status: "anonymous",
|
|
44
|
+
});
|
|
45
|
+
expect(signOuts).toBe(1);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("uses the trusted desktop bridge", async () => {
|
|
49
|
+
let signOuts = 0;
|
|
50
|
+
const adapter = browser({
|
|
51
|
+
desktop: {
|
|
52
|
+
getUser: () => Promise.resolve(user),
|
|
53
|
+
signOut: () => {
|
|
54
|
+
signOuts += 1;
|
|
55
|
+
return Promise.resolve();
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
expect(await adapter.read()).toMatchObject({
|
|
61
|
+
status: "authenticated",
|
|
62
|
+
mode: "desktop",
|
|
63
|
+
user: { ...user, isAdmin: true },
|
|
64
|
+
});
|
|
65
|
+
expect(await adapter.signOut()).toEqual({
|
|
66
|
+
schemaVersion: 1,
|
|
67
|
+
status: "anonymous",
|
|
68
|
+
});
|
|
69
|
+
expect(signOuts).toBe(1);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("projects explicit and persisted development identity separately", async () => {
|
|
73
|
+
const direct = browser({
|
|
74
|
+
location: new URL("http://localhost:8787/?as_user=development"),
|
|
75
|
+
});
|
|
76
|
+
expect(await direct.read()).toMatchObject({
|
|
77
|
+
status: "authenticated",
|
|
78
|
+
mode: "development",
|
|
79
|
+
user: { id: "development" },
|
|
80
|
+
});
|
|
81
|
+
await expect(direct.signOut()).rejects.toThrow(
|
|
82
|
+
"Development identity cannot be signed out",
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
let desktopSignOuts = 0;
|
|
86
|
+
const directInDesktop = browser({
|
|
87
|
+
location: new URL("http://localhost:8787/?as_user=development"),
|
|
88
|
+
desktop: {
|
|
89
|
+
getUser: () => Promise.resolve(user),
|
|
90
|
+
signOut: () => {
|
|
91
|
+
desktopSignOuts += 1;
|
|
92
|
+
return Promise.resolve();
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
await expect(directInDesktop.signOut()).rejects.toThrow(
|
|
97
|
+
"Development identity cannot be signed out",
|
|
98
|
+
);
|
|
99
|
+
expect(desktopSignOuts).toBe(0);
|
|
100
|
+
|
|
101
|
+
const persisted = browser({
|
|
102
|
+
location: new URL("http://localhost:8787/"),
|
|
103
|
+
embeddedUserId: "development",
|
|
104
|
+
embeddedMode: "development",
|
|
105
|
+
betterAuth: {
|
|
106
|
+
getSession: () => Promise.resolve({ data: null, error: null }),
|
|
107
|
+
signOut: () => Promise.resolve({ data: null, error: null }),
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
expect(await persisted.read()).toMatchObject({
|
|
111
|
+
status: "authenticated",
|
|
112
|
+
mode: "development",
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("keeps embedded development authority ahead of desktop and Better Auth", async () => {
|
|
117
|
+
let desktopReads = 0;
|
|
118
|
+
let desktopSignOuts = 0;
|
|
119
|
+
let sessionReads = 0;
|
|
120
|
+
let betterAuthSignOuts = 0;
|
|
121
|
+
const adapter = browser({
|
|
122
|
+
embeddedUserId: "development",
|
|
123
|
+
embeddedMode: "development",
|
|
124
|
+
desktop: {
|
|
125
|
+
getUser: () => {
|
|
126
|
+
desktopReads += 1;
|
|
127
|
+
return Promise.resolve(user);
|
|
128
|
+
},
|
|
129
|
+
signOut: () => {
|
|
130
|
+
desktopSignOuts += 1;
|
|
131
|
+
return Promise.resolve();
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
betterAuth: {
|
|
135
|
+
getSession: () => {
|
|
136
|
+
sessionReads += 1;
|
|
137
|
+
return Promise.resolve({ data: { user }, error: null });
|
|
138
|
+
},
|
|
139
|
+
signOut: () => {
|
|
140
|
+
betterAuthSignOuts += 1;
|
|
141
|
+
return Promise.resolve({ data: { success: true }, error: null });
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
expect(await adapter.read()).toEqual({
|
|
147
|
+
schemaVersion: 1,
|
|
148
|
+
status: "authenticated",
|
|
149
|
+
mode: "development",
|
|
150
|
+
user: {
|
|
151
|
+
id: "development",
|
|
152
|
+
name: "Local developer",
|
|
153
|
+
email: "dev@localhost",
|
|
154
|
+
isAdmin: true,
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
expect(desktopReads).toBe(0);
|
|
158
|
+
expect(sessionReads).toBe(0);
|
|
159
|
+
await expect(adapter.signOut()).rejects.toThrow(
|
|
160
|
+
"Development identity cannot be signed out",
|
|
161
|
+
);
|
|
162
|
+
expect(desktopSignOuts).toBe(0);
|
|
163
|
+
expect(betterAuthSignOuts).toBe(0);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("surfaces Better Auth failure without fabricating success", async () => {
|
|
167
|
+
const adapter = browser({
|
|
168
|
+
betterAuth: {
|
|
169
|
+
getSession: () => Promise.resolve({ data: { user }, error: null }),
|
|
170
|
+
signOut: () =>
|
|
171
|
+
Promise.resolve({
|
|
172
|
+
data: null,
|
|
173
|
+
error: { message: "server unavailable" },
|
|
174
|
+
}),
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
await expect(adapter.signOut()).rejects.toThrow("server unavailable");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("projects only the gateway-owned admin boolean", async () => {
|
|
181
|
+
expect(await browser({ embeddedIsAdmin: false }).read()).toMatchObject({
|
|
182
|
+
user: { id: "alice", isAdmin: false },
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { developmentUserFromUrl } from "./development-login.js";
|
|
2
|
+
|
|
3
|
+
interface AuthUserLike {
|
|
4
|
+
id?: unknown;
|
|
5
|
+
name?: unknown;
|
|
6
|
+
email?: unknown;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface BetterAuthResult {
|
|
10
|
+
data?: { user?: AuthUserLike } | null | unknown;
|
|
11
|
+
error?: { message?: string } | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface HostedBetterAuthAdapter {
|
|
15
|
+
getSession(): Promise<BetterAuthResult>;
|
|
16
|
+
signOut(): Promise<BetterAuthResult>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface HostedDesktopAuthAdapter {
|
|
20
|
+
getUser(): Promise<AuthUserLike | null>;
|
|
21
|
+
signOut(): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface HostedAuthAdapterInput {
|
|
25
|
+
location: URL;
|
|
26
|
+
embeddedUserId?: string;
|
|
27
|
+
embeddedMode?: "anonymous" | "better-auth" | "development";
|
|
28
|
+
embeddedIsAdmin?: boolean;
|
|
29
|
+
betterAuth: HostedBetterAuthAdapter;
|
|
30
|
+
desktop?: HostedDesktopAuthAdapter;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function userProjection(
|
|
34
|
+
user: AuthUserLike,
|
|
35
|
+
isAdmin: boolean,
|
|
36
|
+
fallbackName = "FrockBot user",
|
|
37
|
+
) {
|
|
38
|
+
if (typeof user.id !== "string" || user.id.length === 0) {
|
|
39
|
+
throw new Error("Authenticated user id is invalid");
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
id: user.id,
|
|
43
|
+
name:
|
|
44
|
+
typeof user.name === "string" && user.name.length > 0
|
|
45
|
+
? user.name
|
|
46
|
+
: fallbackName,
|
|
47
|
+
email: typeof user.email === "string" ? user.email : "",
|
|
48
|
+
isAdmin,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function embeddedDevelopmentUser(
|
|
53
|
+
input: HostedAuthAdapterInput,
|
|
54
|
+
): string | undefined {
|
|
55
|
+
if (
|
|
56
|
+
input.embeddedMode !== "development" ||
|
|
57
|
+
!input.embeddedUserId ||
|
|
58
|
+
input.embeddedUserId === "anonymous"
|
|
59
|
+
) {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
return input.embeddedUserId;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function developmentProjection(userId: string, isAdmin: boolean) {
|
|
66
|
+
return {
|
|
67
|
+
schemaVersion: 1 as const,
|
|
68
|
+
status: "authenticated" as const,
|
|
69
|
+
mode: "development" as const,
|
|
70
|
+
user: {
|
|
71
|
+
id: userId,
|
|
72
|
+
name: "Local developer",
|
|
73
|
+
email: "dev@localhost",
|
|
74
|
+
isAdmin,
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function betterAuthUser(result: BetterAuthResult): AuthUserLike | undefined {
|
|
80
|
+
if (result.error) {
|
|
81
|
+
throw new Error(result.error.message || "Could not check your session");
|
|
82
|
+
}
|
|
83
|
+
if (
|
|
84
|
+
typeof result.data !== "object" ||
|
|
85
|
+
result.data === null ||
|
|
86
|
+
!("user" in result.data)
|
|
87
|
+
) {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
const user = result.data.user;
|
|
91
|
+
return typeof user === "object" && user !== null
|
|
92
|
+
? (user as AuthUserLike)
|
|
93
|
+
: undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function createHostedAuthAdapter(input: HostedAuthAdapterInput) {
|
|
97
|
+
return {
|
|
98
|
+
async read(): Promise<unknown> {
|
|
99
|
+
const developmentUser = developmentUserFromUrl(input.location);
|
|
100
|
+
if (developmentUser) {
|
|
101
|
+
return developmentProjection(
|
|
102
|
+
developmentUser,
|
|
103
|
+
input.embeddedIsAdmin === true,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const embeddedDevelopment = embeddedDevelopmentUser(input);
|
|
108
|
+
if (embeddedDevelopment) {
|
|
109
|
+
return developmentProjection(
|
|
110
|
+
embeddedDevelopment,
|
|
111
|
+
input.embeddedIsAdmin === true,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (input.desktop) {
|
|
116
|
+
const user = await input.desktop.getUser();
|
|
117
|
+
return user
|
|
118
|
+
? {
|
|
119
|
+
schemaVersion: 1,
|
|
120
|
+
status: "authenticated",
|
|
121
|
+
mode: "desktop",
|
|
122
|
+
user: userProjection(user, input.embeddedIsAdmin === true),
|
|
123
|
+
}
|
|
124
|
+
: { schemaVersion: 1, status: "anonymous" };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const user = betterAuthUser(await input.betterAuth.getSession());
|
|
128
|
+
if (user) {
|
|
129
|
+
return {
|
|
130
|
+
schemaVersion: 1,
|
|
131
|
+
status: "authenticated",
|
|
132
|
+
mode: "better-auth",
|
|
133
|
+
user: userProjection(user, input.embeddedIsAdmin === true),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
return { schemaVersion: 1, status: "anonymous" };
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
async signOut(): Promise<unknown> {
|
|
140
|
+
if (
|
|
141
|
+
developmentUserFromUrl(input.location) ||
|
|
142
|
+
embeddedDevelopmentUser(input)
|
|
143
|
+
) {
|
|
144
|
+
throw new Error("Development identity cannot be signed out");
|
|
145
|
+
}
|
|
146
|
+
if (input.desktop) {
|
|
147
|
+
await input.desktop.signOut();
|
|
148
|
+
} else {
|
|
149
|
+
const result = await input.betterAuth.signOut();
|
|
150
|
+
if (result.error) {
|
|
151
|
+
throw new Error(result.error.message || "Sign-out failed");
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return { schemaVersion: 1, status: "anonymous" };
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/// <reference path="../env.d.ts" />
|
|
2
|
+
|
|
3
|
+
import type { ClientPlugin, ClientPluginContext } from "@frockbot/client-core";
|
|
4
|
+
import { authSessionClientKey } from "../shared.js";
|
|
5
|
+
import AuthGate from "./AuthGate.vue";
|
|
6
|
+
import { createBrowserAuthSessionClient } from "./browser.js";
|
|
7
|
+
import "@frockbot/client-core/fonts.css";
|
|
8
|
+
import "./styles.css";
|
|
9
|
+
|
|
10
|
+
export const authClientPlugin: ClientPlugin = (ctx: ClientPluginContext) => [
|
|
11
|
+
ctx.provide(authSessionClientKey, createBrowserAuthSessionClient()),
|
|
12
|
+
ctx.slot({ slot: "root", order: 20_000, component: AuthGate }),
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
export default authClientPlugin;
|