skills/oauth/SKILL.md
# OAuth Implementation Checklist Better Auth + Drizzle + Hono + Cloudflare Worker. Every step is a checkbox. Do not skip any. Do them in order. --- ## 1. Rules - [ ] Never write auth SQL by hand - [ ] Always generate auth schema with Better Auth CLI first - [ ] Always generate SQL migrations with Drizzle Kit second - [ ] Always apply migrations with Drizzle Kit third - [ ] Keep local DB URL in `.dev.local` as `LOCAL_DATABASE_URL` (single source of truth) - [ ] Keep `dev`, `drizzle.config.ts`
npx skillsauth add theprimeagen/skills skills/oauthInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Better Auth + Drizzle + Hono + Cloudflare Worker. Every step is a checkbox. Do not skip any. Do them in order.
.dev.local as LOCAL_DATABASE_URL (single source of truth)dev, drizzle.config.ts, drizzle.local.config.ts, and src/lib/auth.ts aligned to .dev.localbun add better-auth drizzle-orm hono pg dotenv
bun add -d drizzle-kit wrangler @types/pg
package.json has these in dependencies:
better-authdrizzle-ormhonopgdotenvpackage.json has these in devDependencies:
drizzle-kitwrangler.dev.local with exactly:LOCAL_DATABASE_URL="postgresql://postgres:[email protected]:5432/<ProjectName>"
.dev.vars with:BASE_URL=http://localhost:<SitePort>
BETTER_AUTH_SECRET=<generate-a-long-random-secret>
TWITTER_CLIENT_ID=<your-twitter-client-id>
TWITTER_CLIENT_SECRET=<your-twitter-client-secret>
ALLOWED_ORIGINS=http://localhost:<SitePort>
.dev.local is in .gitignore.dev.vars is in .gitignoresrc/types/env.ts:export type AppEnv = {
Bindings: {
ASSETS: {
fetch(request: Request): Promise<Response>;
};
HYPERDRIVE: {
connectionString: string;
};
ALLOWED_ORIGINS?: string;
BASE_URL: string;
BETTER_AUTH_SECRET?: string;
SESSION_SIGNING_KEY?: string;
TWITTER_CLIENT_ID: string;
TWITTER_CLIENT_SECRET: string;
};
};
HYPERDRIVE (DB connection via Cloudflare Hyperdrive)BASE_URL (origin for callback URL construction)BETTER_AUTH_SECRET (session signing)SESSION_SIGNING_KEY (fallback session signing)TWITTER_CLIENT_IDTWITTER_CLIENT_SECRETALLOWED_ORIGINS (optional, comma-separated trusted origins)This file is ONLY used by the Better Auth CLI for schema generation. It is NOT the runtime config.
src/lib/auth.ts:import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { config } from "dotenv";
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "../db/auth-schema";
config({ path: ".dev.local" });
const connectionString =
process.env["LOCAL_DATABASE_URL"] ??
"postgresql://postgres:[email protected]:5432/<ProjectName>";
const dbClient = new Pool({ connectionString });
const db = drizzle(dbClient, { schema });
export const auth = betterAuth({
baseURL: process.env["BASE_URL"] ?? "http://localhost:<SitePort>",
basePath: "/api/auth",
secret:
process.env["BETTER_AUTH_SECRET"] ??
process.env["SESSION_SIGNING_KEY"] ??
"replace-me-before-production",
database: drizzleAdapter(db, {
provider: "pg",
schema,
}),
socialProviders: {
twitter: {
clientId: process.env["TWITTER_CLIENT_ID"] ?? "",
clientSecret: process.env["TWITTER_CLIENT_SECRET"] ?? "",
},
},
trustedOrigins: [
process.env["BASE_URL"] ?? "http://localhost:<SitePort>",
"http://localhost:<SitePort>",
],
advanced: {
useSecureCookies: true,
},
});
LOCAL_DATABASE_URL from .dev.localbasePath is /api/authsocialProviders.twitter is configured* as schema from "../db/auth-schema"Your app needs a table to map the provider's account id to your own user identity.
src/db/schema.ts with at minimum an app user table:import { pgTable, text } from "drizzle-orm/pg-core";
export const appUser = pgTable("app_user", {
xId: text("x_id").primaryKey(),
mashId: text("mash_id"),
});
app_user has x_id as primary key (this will hold the Twitter account_id from the Better Auth account table)package.json:{
"scripts": {
"auth:schema": "bunx @better-auth/cli@latest generate --config ./src/lib/auth.ts --output ./src/db/auth-schema.ts --yes",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:migrate:local": "drizzle-kit migrate --config drizzle.local.config.ts",
"db:migrate:remote": "drizzle-kit migrate --config drizzle.remote.config.ts"
}
}
auth:schema points --config at ./src/lib/auth.tsauth:schema points --output at ./src/db/auth-schema.tsdrizzle.config.ts:import { defineConfig } from "drizzle-kit";
import { config } from "dotenv";
config({ path: ".dev.local" });
const connectionString =
process.env.LOCAL_DATABASE_URL ??
"postgresql://postgres:[email protected]:5432/<ProjectName>";
if (!connectionString) {
throw new Error("Set LOCAL_DATABASE_URL before running Drizzle commands.");
}
export default defineConfig({
out: "./drizzle/migrations",
schema: ["./src/db/schema.ts", "./src/db/auth-schema.ts"],
dialect: "postgresql",
dbCredentials: {
url: connectionString,
},
});
drizzle.local.config.ts (identical but without the throw guard):import { defineConfig } from "drizzle-kit";
import { config } from "dotenv";
config({ path: ".dev.local" });
const connectionString =
process.env.LOCAL_DATABASE_URL ??
"postgresql://postgres:[email protected]:5432/<ProjectName>";
export default defineConfig({
out: "./drizzle/migrations",
schema: ["./src/db/schema.ts", "./src/db/auth-schema.ts"],
dialect: "postgresql",
dbCredentials: {
url: connectionString,
},
});
schema array:
./src/db/schema.ts./src/db/auth-schema.tsbun run db:local
src/db/auth-schema.ts:bun run auth:schema
src/db/auth-schema.ts was generated and contains these tables:
user (id, name, email, emailVerified, image, createdAt, updatedAt)session (id, expiresAt, token, createdAt, updatedAt, ipAddress, userAgent, userId)account (id, accountId, providerId, userId, accessToken, refreshToken, idToken, accessTokenExpiresAt, refreshTokenExpiresAt, scope, password, createdAt, updatedAt)verification (id, identifier, value, expiresAt, createdAt, updatedAt)userRelations (has many sessions, has many accounts)sessionRelations (belongs to user)accountRelations (belongs to user)bun run db:generate
drizzle/migrations/bun run db:migrate:local
auth:schema THEN db:generate THEN db:migrate:localThis is the runtime config used by the actual Worker. Different from the CLI config in Step 5.
src/auth/runtime.ts:import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "../db/auth-schema";
import type { AppEnv } from "../types/env";
type AuthBindings = AppEnv["Bindings"];
const DEFAULT_BASE_URL = "http://localhost:<SitePort>";
const LOCAL_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1"]);
function parseCSV(value?: string): string[] {
if (!value) {
return [];
}
return value
.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
function normalizeBaseURL(input: string): string | null {
try {
const parsed = new URL(input);
if (LOCAL_HOSTNAMES.has(parsed.hostname) && parsed.protocol === "https:") {
parsed.protocol = "http:";
}
return parsed.origin;
} catch {
return null;
}
}
function resolveBaseURL(bindings: AuthBindings, requestURL?: string): string {
const configuredBaseURL = bindings.BASE_URL?.trim();
if (configuredBaseURL) {
const normalizedConfiguredBaseURL = normalizeBaseURL(configuredBaseURL);
if (normalizedConfiguredBaseURL) {
return normalizedConfiguredBaseURL;
}
}
if (requestURL) {
const normalizedRequestBaseURL = normalizeBaseURL(requestURL);
if (normalizedRequestBaseURL) {
return normalizedRequestBaseURL;
}
}
return DEFAULT_BASE_URL;
}
function resolveSecret(bindings: AuthBindings): string {
return (
bindings.BETTER_AUTH_SECRET?.trim() ||
bindings.SESSION_SIGNING_KEY?.trim() ||
"replace-me-before-production"
);
}
function resolveTrustedOrigins(
bindings: AuthBindings,
baseURL: string,
): string[] {
return Array.from(
new Set([
...parseCSV(bindings.ALLOWED_ORIGINS),
bindings.BASE_URL?.trim() ?? "",
baseURL,
"<ProductionURL>",
DEFAULT_BASE_URL,
]),
).filter((origin) => origin.length > 0);
}
export function createRequestAuth(
bindings: AuthBindings,
requestURL?: string,
) {
const baseURL = resolveBaseURL(bindings, requestURL);
const useSecureCookies = baseURL.startsWith("https://");
const dbClient = new Pool({
connectionString: bindings.HYPERDRIVE.connectionString,
});
const db = drizzle(dbClient, { schema });
return betterAuth({
baseURL,
basePath: "/api/auth",
secret: resolveSecret(bindings),
database: drizzleAdapter(db, {
provider: "pg",
schema,
}),
socialProviders: {
twitter: {
clientId: bindings.TWITTER_CLIENT_ID,
clientSecret: bindings.TWITTER_CLIENT_SECRET,
disableDefaultScope: true,
scope: ["users.read", "tweet.read", "offline.access"],
},
},
trustedOrigins: resolveTrustedOrigins(bindings, baseURL),
advanced: {
useSecureCookies,
},
});
}
bindings.HYPERDRIVE.connectionString (not LOCAL_DATABASE_URL)useSecureCookies dynamically based on HTTPS (not hardcoded true)disableDefaultScope: true and explicit scopes on twitter providerALLOWED_ORIGINS env var + computed valuesprocess.envsrc/routes/auth.ts:import { Hono, type Context } from "hono";
import { and, eq } from "drizzle-orm";
import { drizzle } from "drizzle-orm/node-postgres";
import { Client } from "pg";
import { createRequestAuth } from "../auth/runtime";
import { account } from "../db/auth-schema";
import { appUser } from "../db/schema";
import type { AppEnv } from "../types/env";
type MeResponse = {
name: string;
profile: string | null;
x_id: string;
mash_id: string | null;
};
function appendSetCookieHeaders(source: Headers, target: Headers): void {
for (const [name, value] of source.entries()) {
if (name.toLowerCase() === "set-cookie") {
target.append("set-cookie", value);
}
}
}
export async function loginTwitter(c: Context<AppEnv>): Promise<Response> {
const callbackURL = "/";
const auth = createRequestAuth(c.env, c.req.url);
const authResponse = await auth.api.signInSocial({
body: {
provider: "twitter",
callbackURL,
},
headers: c.req.raw.headers,
asResponse: true,
});
const locationHeader = authResponse.headers.get("location");
let redirectURL = locationHeader;
if (!redirectURL) {
const payload = (await authResponse.clone().json().catch(() => null)) as {
url?: string;
} | null;
redirectURL = payload?.url ?? null;
}
if (!redirectURL) {
return authResponse;
}
const headers = new Headers({ location: redirectURL });
appendSetCookieHeaders(authResponse.headers, headers);
return new Response(null, {
status: 302,
headers,
});
}
export async function getMe(c: Context<AppEnv>): Promise<Response> {
const auth = createRequestAuth(c.env, c.req.url);
const session = await auth.api.getSession({
headers: c.req.raw.headers,
});
if (!session?.user) {
return c.json({ error: "Unauthorized" }, 401);
}
const client = new Client({
connectionString: c.env.HYPERDRIVE.connectionString,
});
await client.connect();
try {
const db = drizzle(client, { schema: { account, appUser } });
const twitterAccount = await db.query.account.findFirst({
where: (fields) =>
and(
eq(fields.userId, session.user.id),
eq(fields.providerId, "twitter"),
),
columns: {
accountId: true,
},
});
if (!twitterAccount) {
return c.json({ error: "Twitter account not linked" }, 403);
}
const existingAppUser = await db.query.appUser.findFirst({
where: (fields) => eq(fields.xId, twitterAccount.accountId),
});
const resolvedAppUser =
existingAppUser ??
(
await db
.insert(appUser)
.values({
xId: twitterAccount.accountId,
})
.returning({
xId: appUser.xId,
mashId: appUser.mashId,
})
)[0];
if (!resolvedAppUser) {
throw new Error("Failed to resolve app user.");
}
const user: MeResponse = {
name: session.user.name,
profile: session.user.image ?? null,
x_id: resolvedAppUser.xId,
mash_id: resolvedAppUser.mashId,
};
return c.json(user);
} finally {
await client.end();
}
}
export async function logout(c: Context<AppEnv>): Promise<Response> {
const auth = createRequestAuth(c.env, c.req.url);
return auth.api.signOut({
headers: c.req.raw.headers,
asResponse: true,
});
}
async function forwardBetterAuth(c: Context<AppEnv>): Promise<Response> {
return createRequestAuth(c.env, c.req.url).handler(c.req.raw);
}
export const authRoutes = new Hono<AppEnv>()
.get("/login/twitter", loginTwitter)
.get("/callback/:provider", forwardBetterAuth)
.get("/me", getMe)
.post("/logout", logout)
.on(["GET", "POST"], "/*", forwardBetterAuth);
export function registerAuthRoutes(app: Hono<AppEnv>) {
return app.route("/api/auth", authRoutes);
}
GET /login/twitter -> loginTwitterGET /callback/:provider -> forwardBetterAuthGET /me -> getMePOST /logout -> logoutGET|POST /* -> forwardBetterAuth (catch-all, must be last)loginTwitter does all three of these:
auth.api.signInSocial with provider: "twitter" and callbackURL: "/"location header OR JSON body url fieldset-cookie headers from Better Auth response into the final 302getMe does all of these in order:
auth.api.getSession({ headers })account where userId = session.user.id AND providerId = "twitter"app_user row by xId = account.accountId{ name, profile, x_id, mash_id }logout calls auth.api.signOut({ headers, asResponse: true })createRequestAuth(...).handler(c.req.raw)src/index.ts, register auth routes:import { Hono } from "hono";
import { registerAuthRoutes } from "./routes/auth";
import type { AppEnv } from "./types/env";
const app = registerAuthRoutes(new Hono<AppEnv>());
export default app;
registerAuthRoutes mounts auth routes at /api/authwrangler.jsonc:{
"name": "<ProjectName>",
"main": "src/index.ts",
"compatibility_flags": ["nodejs_compat"],
"assets": {
"binding": "ASSETS",
"directory": "./dist"
},
"hyperdrive": [
{
"binding": "HYPERDRIVE",
"id": "<your-hyperdrive-id>",
"localConnectionString": "postgresql://postgres:[email protected]:5432/<ProjectName>"
}
]
}
nodejs_compat is in compatibility_flags (required for pg module)ASSETS binding points to ./distHYPERDRIVE binding has a valid localConnectionStringhttp://localhost:<SitePort>/api/auth/callback/twitter<ProductionURL>/api/auth/callback/twitterhttps://www.tweetmash.com/api/auth/callback/twitterusers.read tweet.read offline.accessTWITTER_CLIENT_ID and TWITTER_CLIENT_SECRET into .dev.varsbun run auth:schema
bun run db:generate
bun run db:migrate:local
wrangler dev
curl -i http://localhost:<SitePort>/api/auth/me
401 with {"error":"Unauthorized"}curl -i http://localhost:<SitePort>/api/auth/login/twitter
302 with Location header pointing to TwitterSet-Cookie headers present in responsehttp://localhost:<SitePort>Sign in with XGET /api/auth/login/twitter -> 302 to Twitter/api/auth/callback/twitter?code=...&state=.../ (because callbackURL is /)Log outGET /api/auth/me now returns 401user table has a row for the logged-in useraccount table has a row with provider_id = 'twitter' and valid account_idsession table has a row with valid token and expires_atapp_user table has a row with x_id matching account.account_id| Route | Method | Happy Path | Failure |
|---|---|---|---|
| /api/auth/login/twitter | GET | 302 + Location + Set-Cookie | Better Auth passthrough if no redirect URL |
| /api/auth/callback/twitter | GET | Better Auth redirect to / | Better Auth error (state/PKCE mismatch) |
| /api/auth/me | GET | 200 {name, profile, x_id, mash_id} | 401 (no session) or 403 (no twitter link) |
| /api/auth/logout | POST | Better Auth 2xx | Better Auth managed |
| /api/auth/* | GET/POST | Better Auth managed | Better Auth managed |
src/db/auth-schema.ts:
useraccountsessionverificationgetMe uses auth.api.getSession({ headers: c.req.raw.headers })loginTwitter preserves Set-Cookie headers via appendSetCookieHeadersuseSecureCookies is dynamic in runtime (true only when baseURL is HTTPS)BETTER_AUTH_SECRET -> SESSION_SIGNING_KEY -> fallbackALLOWED_ORIGINS + BASE_URL + resolved baseURL + hardcoded defaultsBETTER_AUTH_SECRET to a strong random value (never use fallback)BASE_URL to exact production origin (e.g., <ProductionURL>)ALLOWED_ORIGINS restricted to real origins onlyThe account table stores provider tokens. Current getMe only reads accountId.
const twitterAccountWithTokens = await db.query.account.findFirst({
where: (fields) =>
and(eq(fields.userId, session.user.id), eq(fields.providerId, "twitter")),
columns: {
accountId: true,
accessToken: true,
refreshToken: true,
accessTokenExpiresAt: true,
refreshTokenExpiresAt: true,
scope: true,
},
});
account table:
accessToken (text, nullable)refreshToken (text, nullable)idToken (text, nullable)accessTokenExpiresAt (timestamp, nullable)refreshTokenExpiresAt (timestamp, nullable)scope (text, nullable)wrangler secret put TWITTER_CLIENT_IDwrangler secret put TWITTER_CLIENT_SECRETwrangler secret put BETTER_AUTH_SECRETwrangler secret put SESSION_SIGNING_KEY (optional, fallback)BASE_URL=<ProductionURL>ALLOWED_ORIGINS=<ProductionURL>,https://www.tweetmash.com<ProductionURL>/api/auth/callback/twitterhttps://www.tweetmash.com/api/auth/callback/twitter (if using www)bun run auth:schema
bun run db:generate
bun run db:migrate:remote
wrangler deploy
//api/auth/me returns signed-in identityGET /api/auth/login/twitter is not 302:
TWITTER_CLIENT_ID and TWITTER_CLIENT_SECRET are setBASE_URL is set and matches browser originSet-Cookie headers (OAuth state + PKCE verifier cookies)/api/auth/me returns 401 after successful login:
BETTER_AUTH_SECRET is the same value that signed the session/api/auth/me returns 403:
account with provider_id = 'twitter' for that userALLOWED_ORIGINS and BASE_URL match the actual request origin exactlytools
# Neovim Lua API Reference This document contains type stubs and API references for Neovim's Lua API. Use this as a reference when writing Neovim plugins or configurations in Lua. --- ## api The following are type stubs for all the functions available on `vim.api.*`. Prefer these functions where possible. ```lua vim.api = {} vim.api.nvim__buf_debug_extmarks(buffer, keys, dot) vim.api.nvim__buf_stats(buffer) vim.api.nvim__complete_set(index, opts) vim.api.nvim__get_lib_dir() vim.api.nvim
development
# Neovim Treesitter API Reference This document contains type stubs and API references for Neovim's treesitter Lua API. Use this as a reference when working with treesitter in Neovim Lua. --- ## tsnode TSNode methods - represents a specific element in a parsed syntax tree. Use these methods to navigate and inspect nodes. ```lua function TSNode:parent() end function TSNode:next_sibling() end function TSNode:prev_sibling() end function TSNode:next_named_sibling() end function TSNode:prev_name
tools
# Neovim LSP API Reference This document contains function definitions from Neovim's LSP help docs. Use this as a reference when working with LSP in Neovim Lua. --- ## lsp Functions extracted from `lsp.txt`. ```lua vim.lsp.buf_attach_client({bufnr}, {client_id}) vim.lsp.buf_detach_client({bufnr}, {client_id}) vim.lsp.buf_is_attached({bufnr}, {client_id}) vim.lsp.buf_notify({bufnr}, {method}, {params}) vim.lsp.buf_request_all({bufnr}, {method}, {params}, {handler}) vim.lsp.buf_request_sync({
tools
# Neovim Diagnostics API Reference This document contains function definitions for Neovim's diagnostics Lua API. Use this as a reference when working with diagnostics in Neovim Lua. --- ## diagnostic vim.diagnostic APIs, types, and helpers. ```lua function get_qf_id_for_title(title) function __newindex(t, name, handler) function __index(t, bufnr) function callback() function to_severity(severity) function severity_predicate(severity) function filter_by_severity(severity, diagnostics) functi