Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion playgrounds/better-auth-hono/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions playgrounds/better-auth-hono/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
153 changes: 153 additions & 0 deletions playgrounds/better-auth-hono/public/device.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Device Authorization — Writing Tools</title>
<style>
body { font-family: sans-serif; max-width: 520px; margin: 3rem auto; padding: 0 1.5rem; line-height: 1.5; }
h1 { font-size: 1.4rem; }
.code { font-family: monospace; font-size: 1.6rem; letter-spacing: 0.15em; background: #f4f4f4; padding: 0.5rem 1rem; border-radius: 6px; display: inline-block; }
button { padding: 0.6rem 1.5rem; cursor: pointer; font-size: 1rem; border-radius: 6px; border: 1px solid #ccc; margin-right: 0.75rem; }
.approve { background: #16a34a; color: white; border-color: #16a34a; }
.deny { background: #dc2626; color: white; border-color: #dc2626; }
.muted { color: #666; font-size: 0.9rem; }
#status { margin-top: 1.5rem; padding: 1rem; border-radius: 6px; background: #f9f9f9; }
.ok { color: #16a34a; font-weight: bold; }
.err { color: #dc2626; font-weight: bold; }
</style>
</head>
<body>
<h1>Device Authorization</h1>
<p class="muted">A device (e.g. the Word add-in) is requesting access to your account.</p>

<div id="content">Loading…</div>
<div id="status"></div>

<script>
const BASE = window.location.origin; // same origin as backend (3001)
const params = new URLSearchParams(window.location.search);
const userCode = params.get("user_code");

const content = document.getElementById("content");
const statusEl = document.getElementById("status");

// 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" });
if (!res.ok) return null;
const data = await res.json();
return data?.user ? data : null;
}

async function claimAndGetStatus() {
const res = await fetch(
`${BASE}/api/auth/device?user_code=${encodeURIComponent(userCode)}`,
{ credentials: "include" }
);
return { ok: res.ok, data: await res.json() };
}

async function signIn() {
const callbackURL = window.location.href;
const res = await fetch(`${BASE}/api/auth/sign-in/social`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider: "google", callbackURL }),
});
const data = await res.json();
if (data?.url) window.location.href = data.url;
else setStatus(el("span", "err", "Sign-in error: " + JSON.stringify(data)));
}

async function approve() {
const res = await fetch(`${BASE}/api/auth/device/approve`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userCode }),
});
const data = await res.json();
if (res.ok && data?.success) {
showContent();
setStatus(el("p", "ok", "Authorization complete. You can close this tab and return to Word."));
} else {
setStatus(el("span", "err", "Approve failed: " + JSON.stringify(data)));
}
}

async function deny() {
const res = await fetch(`${BASE}/api/auth/device/deny`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userCode }),
});
const data = await res.json();
if (res.ok && data?.success) {
showContent();
setStatus(el("p", "err", "Authorization denied. The device will not be granted access."));
} else {
setStatus(el("span", "err", "Deny failed: " + JSON.stringify(data)));
}
}

async function init() {
if (!userCode) {
showContent(el("p", "err", "No user_code in the URL."));
return;
}

const session = await getSession();

if (!session) {
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;
}

const { ok, data } = await claimAndGetStatus();
if (!ok) {
showContent(el("p", "err", data?.error_description ?? "Invalid or expired code."));
return;
}

if (data.status !== "pending") {
const p = el("p"); p.append("This code has already been "); const s = el("strong", null, data.status); p.append(s, "."); showContent(p);
return;
}

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();
</script>
</body>
</html>
4 changes: 2 additions & 2 deletions playgrounds/better-auth-hono/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,8 @@ <h1>Hono + Better Auth POC</h1>
}

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) {
Expand Down
23 changes: 21 additions & 2 deletions playgrounds/better-auth-hono/src/auth.ts
Original file line number Diff line number Diff line change
@@ -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 ?? "",
Expand Down
7 changes: 5 additions & 2 deletions playgrounds/better-auth-hono/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
17 changes: 17 additions & 0 deletions playgrounds/better-auth-hono/src/taskpane-server.ts
Original file line number Diff line number Diff line change
@@ -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)`);
});
Loading