From b53f3dd1ec219aa038c29409a3ebba0de5eaefa2 Mon Sep 17 00:00:00 2001 From: nhyiramante1 Date: Thu, 11 Jun 2026 16:16:31 -0400 Subject: [PATCH 1/2] poc: add Better Auth device authorization flow --- .../better-auth-hono/package-lock.json | 2 +- playgrounds/better-auth-hono/package.json | 5 +- .../better-auth-hono/public/device.html | 143 +++++++++++++++ .../better-auth-hono/public/index.html | 4 +- playgrounds/better-auth-hono/src/auth.ts | 23 ++- playgrounds/better-auth-hono/src/index.ts | 7 +- .../better-auth-hono/src/taskpane-server.ts | 17 ++ .../better-auth-hono/taskpane/index.html | 165 ++++++++++++++++++ 8 files changed, 357 insertions(+), 9 deletions(-) create mode 100644 playgrounds/better-auth-hono/public/device.html create mode 100644 playgrounds/better-auth-hono/src/taskpane-server.ts create mode 100644 playgrounds/better-auth-hono/taskpane/index.html diff --git a/playgrounds/better-auth-hono/package-lock.json b/playgrounds/better-auth-hono/package-lock.json index 292cd85a..da9ac149 100644 --- a/playgrounds/better-auth-hono/package-lock.json +++ b/playgrounds/better-auth-hono/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "dependencies": { "@hono/node-server": "^2.0.4", - "better-auth": "^1.2.0", + "better-auth": "1.6.14", "better-sqlite3": "^12.0.0", "dotenv": "^17.4.2", "hono": "^4.7.0" diff --git a/playgrounds/better-auth-hono/package.json b/playgrounds/better-auth-hono/package.json index c87c0c0d..9decb29b 100644 --- a/playgrounds/better-auth-hono/package.json +++ b/playgrounds/better-auth-hono/package.json @@ -5,11 +5,12 @@ "type": "module", "scripts": { "dev": "tsx watch --env-file .env src/index.ts", - "start": "tsx --env-file .env src/index.ts" + "start": "tsx --env-file .env src/index.ts", + "dev:taskpane": "tsx watch src/taskpane-server.ts" }, "dependencies": { "@hono/node-server": "^2.0.4", - "better-auth": "^1.2.0", + "better-auth": "1.6.14", "better-sqlite3": "^12.0.0", "dotenv": "^17.4.2", "hono": "^4.7.0" diff --git a/playgrounds/better-auth-hono/public/device.html b/playgrounds/better-auth-hono/public/device.html new file mode 100644 index 00000000..c25259e6 --- /dev/null +++ b/playgrounds/better-auth-hono/public/device.html @@ -0,0 +1,143 @@ + + + + + + Device Authorization — Writing Tools + + + +

Device Authorization

+

A device (e.g. the Word add-in) is requesting access to your account.

+ +
Loading…
+
+ + + + diff --git a/playgrounds/better-auth-hono/public/index.html b/playgrounds/better-auth-hono/public/index.html index fcda6a11..0ee23a1b 100644 --- a/playgrounds/better-auth-hono/public/index.html +++ b/playgrounds/better-auth-hono/public/index.html @@ -71,8 +71,8 @@

Hono + Better Auth POC

} async function signOut() { - await fetch(`${BASE}/api/auth/sign-out`, { method: "POST", credentials: "include" }); - window.location.reload(); + const res = await fetch(`${BASE}/api/auth/sign-out`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: "{}" }); + if (res.ok) window.location.reload(); } async function callProtected(mode) { diff --git a/playgrounds/better-auth-hono/src/auth.ts b/playgrounds/better-auth-hono/src/auth.ts index 6abade93..28a6e564 100644 --- a/playgrounds/better-auth-hono/src/auth.ts +++ b/playgrounds/better-auth-hono/src/auth.ts @@ -1,17 +1,36 @@ import { betterAuth } from "better-auth"; -import { bearer } from "better-auth/plugins"; +import { bearer, deviceAuthorization } from "better-auth/plugins"; import Database from "better-sqlite3"; import path from "path"; import { fileURLToPath } from "url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); +// Device-flow client identifier. This is the app's own ID for the device +// authorization grant — it is NOT the Google OAuth client ID. +export const DEVICE_CLIENT_ID = "writing-tools-word-poc"; + export const auth = betterAuth({ // Run `npx @better-auth/cli migrate` after adding or changing plugins. database: new Database(path.join(__dirname, "../db/auth.db")), baseURL: process.env.BETTER_AUTH_URL ?? "http://localhost:3001", secret: process.env.BETTER_AUTH_SECRET, - plugins: [bearer()], + plugins: [ + bearer(), + deviceAuthorization({ + // Browser-facing approval page the user is sent to. + verificationUri: "/device.html", + // Device codes expire after 10 minutes if never approved. + expiresIn: "10m", + // Task pane must not poll faster than every 5 seconds. + interval: "5s", + // Only issue device codes for this app's known client ID. + validateClient: (clientId) => clientId === DEVICE_CLIENT_ID, + // Required by this plugin version's runtime options schema even though + // the TS type marks it optional. Empty = use the default deviceCode table. + schema: {}, + }), + ], socialProviders: { google: { clientId: process.env.GOOGLE_CLIENT_ID ?? "", diff --git a/playgrounds/better-auth-hono/src/index.ts b/playgrounds/better-auth-hono/src/index.ts index 4166f2db..c5cc420c 100644 --- a/playgrounds/better-auth-hono/src/index.ts +++ b/playgrounds/better-auth-hono/src/index.ts @@ -10,11 +10,14 @@ import chat from "./routes/chat.js"; const app = new Hono(); const PORT = 3001; -// CORS — allow the static test page and future frontend origins. +// CORS — allow the backend's own static pages (3001) AND the separate +// task-pane simulator origin (3002). The 3002 origin reproduces the Word +// task-pane / browser split: it has NO Better Auth cookie and must rely +// entirely on the bearer token returned by the device flow. app.use( "*", cors({ - origin: [`http://localhost:${PORT}`], + origin: ["http://localhost:3001", "http://localhost:3002"], allowHeaders: ["Content-Type", "Authorization"], allowMethods: ["GET", "POST", "OPTIONS"], credentials: true, diff --git a/playgrounds/better-auth-hono/src/taskpane-server.ts b/playgrounds/better-auth-hono/src/taskpane-server.ts new file mode 100644 index 00000000..20392cba --- /dev/null +++ b/playgrounds/better-auth-hono/src/taskpane-server.ts @@ -0,0 +1,17 @@ +import { Hono } from "hono"; +import { serve } from "@hono/node-server"; +import { serveStatic } from "@hono/node-server/serve-static"; + +// Standalone static server for the task-pane simulator. +// Runs on a DIFFERENT origin (3002) than the backend (3001) so that +// cookies set during browser login never reach this origin. The simulator +// must succeed using only the bearer token from the device flow. +const app = new Hono(); +const PORT = 3002; + +app.use("/*", serveStatic({ root: "./taskpane" })); + +serve({ fetch: app.fetch, port: PORT }, () => { + console.log(`Task-pane simulator at http://localhost:${PORT}`); + console.log(`(backend expected at http://localhost:3001)`); +}); diff --git a/playgrounds/better-auth-hono/taskpane/index.html b/playgrounds/better-auth-hono/taskpane/index.html new file mode 100644 index 00000000..e043926b --- /dev/null +++ b/playgrounds/better-auth-hono/taskpane/index.html @@ -0,0 +1,165 @@ + + + + + + Task-Pane Simulator (Device Flow) + + + +

Task-Pane Simulator

+

+ Origin http://localhost:3002 — no Better Auth cookie here. + Backend is http://localhost:3001. Success depends only on the + bearer token from the device flow. +

+ +
+ + + +
+ +
+
+ + + + From e5a06dd247aee15ad92f191794873626f5c663ae Mon Sep 17 00:00:00 2001 From: nhyiramante1 Date: Fri, 12 Jun 2026 09:57:41 -0400 Subject: [PATCH 2/2] corrected stale comment and fixed the innerhtml security display issue --- .../better-auth-hono/public/device.html | 68 +++++++++++-------- .../better-auth-hono/taskpane/index.html | 50 ++++++++------ 2 files changed, 70 insertions(+), 48 deletions(-) diff --git a/playgrounds/better-auth-hono/public/device.html b/playgrounds/better-auth-hono/public/device.html index c25259e6..46ffbd23 100644 --- a/playgrounds/better-auth-hono/public/device.html +++ b/playgrounds/better-auth-hono/public/device.html @@ -32,8 +32,27 @@

Device Authorization

const content = document.getElementById("content"); const statusEl = document.getElementById("status"); - function show(html) { content.innerHTML = html; } - function setStatus(html) { statusEl.innerHTML = html; } + // Build helpers — dynamic values always go through textContent, never innerHTML. + function el(tag, cls, text) { + const e = document.createElement(tag); + if (cls) e.className = cls; + if (text !== undefined) e.textContent = text; + return e; + } + function btn(cls, id, label, handler) { + const b = el("button", cls); + b.id = id; + b.textContent = label; + b.onclick = handler; + return b; + } + + function showContent(...nodes) { + content.replaceChildren(...nodes); + } + function setStatus(...nodes) { + statusEl.replaceChildren(...nodes); + } async function getSession() { const res = await fetch(`${BASE}/api/auth/get-session`, { credentials: "include" }); @@ -42,7 +61,6 @@

Device Authorization

return data?.user ? data : null; } - // Hitting GET /device while signed in "claims" the code for this user. async function claimAndGetStatus() { const res = await fetch( `${BASE}/api/auth/device?user_code=${encodeURIComponent(userCode)}`, @@ -52,7 +70,6 @@

Device Authorization

} async function signIn() { - // Return to this same device page (with the code) after Google login. const callbackURL = window.location.href; const res = await fetch(`${BASE}/api/auth/sign-in/social`, { method: "POST", @@ -62,7 +79,7 @@

Device Authorization

}); const data = await res.json(); if (data?.url) window.location.href = data.url; - else setStatus(`Sign-in error: ${JSON.stringify(data)}`); + else setStatus(el("span", "err", "Sign-in error: " + JSON.stringify(data))); } async function approve() { @@ -74,10 +91,10 @@

Device Authorization

}); const data = await res.json(); if (res.ok && data?.success) { - show(""); - setStatus(`

Authorization complete.

You can close this tab and return to Word.

`); + showContent(); + setStatus(el("p", "ok", "Authorization complete. You can close this tab and return to Word.")); } else { - setStatus(`Approve failed: ${JSON.stringify(data)}`); + setStatus(el("span", "err", "Approve failed: " + JSON.stringify(data))); } } @@ -90,51 +107,44 @@

Device Authorization

}); const data = await res.json(); if (res.ok && data?.success) { - show(""); - setStatus(`

Authorization denied.

The device will not be granted access.

`); + showContent(); + setStatus(el("p", "err", "Authorization denied. The device will not be granted access.")); } else { - setStatus(`Deny failed: ${JSON.stringify(data)}`); + setStatus(el("span", "err", "Deny failed: " + JSON.stringify(data))); } } async function init() { if (!userCode) { - show(`

No user_code in the URL.

`); + showContent(el("p", "err", "No user_code in the URL.")); return; } const session = await getSession(); if (!session) { - show(` -

You are verifying code: ${userCode}

-

Sign in to continue.

- - `); - document.getElementById("signin").onclick = signIn; + const codeSpan = el("span", "code", userCode); + const p1 = el("p"); p1.append("You are verifying code: ", codeSpan); + showContent(p1, el("p", null, "Sign in to continue."), btn("approve", "signin", "Sign in with Google", signIn)); return; } - // Signed in — claim the code, then show approve/deny. const { ok, data } = await claimAndGetStatus(); if (!ok) { - show(`

${data?.error_description ?? "Invalid or expired code."}

`); + showContent(el("p", "err", data?.error_description ?? "Invalid or expired code.")); return; } if (data.status !== "pending") { - show(`

This code has already been ${data.status}.

`); + const p = el("p"); p.append("This code has already been "); const s = el("strong", null, data.status); p.append(s, "."); showContent(p); return; } - show(` -

Signed in as ${session.user.email}

-

Approve access for code: ${userCode}

- - - `); - document.getElementById("approve").onclick = approve; - document.getElementById("deny").onclick = deny; + const emailStrong = el("strong", null, session.user.email); + const p1 = el("p"); p1.append("Signed in as ", emailStrong); + const codeSpan = el("span", "code", userCode); + const p2 = el("p"); p2.append("Approve access for code: ", codeSpan); + showContent(p1, p2, btn("approve", "approve", "Approve", approve), btn("deny", "deny", "Deny", deny)); } init(); diff --git a/playgrounds/better-auth-hono/taskpane/index.html b/playgrounds/better-auth-hono/taskpane/index.html index e043926b..1d42d664 100644 --- a/playgrounds/better-auth-hono/taskpane/index.html +++ b/playgrounds/better-auth-hono/taskpane/index.html @@ -45,8 +45,17 @@

Task-Pane Simulator

const logEl = document.getElementById("log"); let polling = false; - function setStatus(html) { statusEl.innerHTML = html; } - function log(html) { logEl.innerHTML = `
${html}
` + logEl.innerHTML; } + function setStatus(...nodes) { + statusEl.replaceChildren(...nodes.map(n => typeof n === "string" ? document.createTextNode(n) : n)); + } + function statusEl2(tag, cls, text) { + const e = document.createElement(tag); if (cls) e.className = cls; e.textContent = text; return e; + } + function log(text) { + const pre = document.createElement("pre"); + pre.textContent = text; + logEl.prepend(pre); + } // 1. Ask the backend for a device + user code. async function startFlow() { @@ -60,7 +69,7 @@

Task-Pane Simulator

}); const data = await res.json(); if (!res.ok) { - setStatus(`device/code failed: ${JSON.stringify(data)}`); + setStatus(statusEl2("span", "err", "device/code failed: " + JSON.stringify(data))); return; } @@ -68,11 +77,14 @@

Task-Pane Simulator

// 2. Show the approval link — user clicks to open in a new tab. // (Future Word adapter uses Office.context.ui.openBrowserWindow.) - setStatus(` - User code: ${data.user_code}
- Open approval page →
- Polling every ${data.interval}s once you approve… - `); + const codeSpan = statusEl2("span", "code", data.user_code); + const link = document.createElement("a"); + link.href = data.verification_uri_complete; + link.target = "_blank"; link.rel = "noopener"; + link.textContent = "Open approval page →"; + const muted = statusEl2("span", "muted", `Polling every ${data.interval}s once you approve…`); + const p1 = document.createElement("span"); p1.append("User code: ", codeSpan); + setStatus(p1, document.createElement("br"), link, document.createElement("br"), muted); // 3. Poll for the token, respecting the interval. pollForToken(data.device_code, data.interval); @@ -100,7 +112,7 @@

Task-Pane Simulator

polling = false; localStorage.setItem(TOKEN_KEY, data.access_token); log(`device/token → SUCCESS\n${JSON.stringify(data, null, 2)}`); - setStatus(`Token received and stored. Now click "Call /api/protected".`); + setStatus(statusEl2("span", "ok", "Token received and stored."), " Now click \"Call /api/protected\"."); return; } @@ -110,21 +122,21 @@

Task-Pane Simulator

setTimeout(tick, interval * 1000); break; case "slow_down": - interval += 5; // back off per spec + interval += 5; log(`slow_down → backing off to ${interval}s`); setTimeout(tick, interval * 1000); break; case "access_denied": polling = false; - setStatus(`Access denied. The user denied the request.`); + setStatus(statusEl2("span", "err", "Access denied."), " The user denied the request."); break; case "expired_token": polling = false; - setStatus(`Code expired. Start the flow again.`); + setStatus(statusEl2("span", "err", "Code expired."), " Start the flow again."); break; default: polling = false; - setStatus(`Stopped: ${JSON.stringify(data)}`); + setStatus(statusEl2("span", "err", "Stopped: " + JSON.stringify(data))); } }; @@ -136,7 +148,7 @@

Task-Pane Simulator

async function callProtected() { const token = localStorage.getItem(TOKEN_KEY); if (!token) { - setStatus(`No stored token. Run the device login first.`); + setStatus(statusEl2("span", "err", "No stored token."), " Run the device login first."); return; } const res = await fetch(`${BACKEND}/api/protected`, { @@ -145,11 +157,11 @@

Task-Pane Simulator

}); const data = await res.json(); log(`/api/protected → ${res.status}\n${JSON.stringify(data, null, 2)}`); - setStatus( - res.ok - ? `${res.status} — authenticated as ${data.email}` - : `${res.status} — ${JSON.stringify(data)}` - ); + if (res.ok) { + setStatus(statusEl2("span", "ok", String(res.status)), " — authenticated as " + data.email); + } else { + setStatus(statusEl2("span", "err", String(res.status)), " — " + JSON.stringify(data)); + } } function clearToken() {