Sessions + OAuth (GitHub, Google) with Drizzle, for Hono and Next.js
Code9 filesContext~670 tokensscan passed
Install
$
genpm add @core/authWhat you get
- Source in src/lib/auth/, 9 files. (23.2 kB)
- AI rules in src/lib/auth/AGENTS.md, plus IDE rule files.
- Env vars added to .env.example: AUTH_SECRET, GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET.
- Resolves @core/db for you.
README
This package has no README.
~670 tokens→ src/lib/auth/AGENTS.md→ .cursor/rules/genpm-core-auth.mdc
This is exactly what your AI reads when it works in src/lib/auth. Nothing else is added to its context.
@core/auth — rules for AI agents
Purpose
Cookie sessions (random token, only its SHA-256 stored, 30-day sliding expiry, rotation) and OAuth login with GitHub
and Google via arctic. Tables: users, auth_sessions, oauth_accounts. No roles or permissions (out of scope:
build them in your app on top of users.id). No passwords.
Map
index.ts— public API:getUserFromCookieHeader,signOut,startOAuth,finishOAuth, session functions,User.adapters/hono.ts—sessionMiddleware,requireUser,authRoutes().adapters/next.ts—loginRoute,callbackRoute,logoutRoute,getUser(cookies).schema.ts— Drizzle tables. Depends on../db(@core/db).
Integration
- Env:
AUTH_SECRET(≥ 32 random chars, e.g.openssl rand -base64 32),GITHUB_CLIENT_ID,GITHUB_CLIENT_SECRET. Optional Google:GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET. Providers without both vars are disabled. - OAuth app callback URL:
<origin>/auth/callback/github(and/auth/callback/google). - Generate and apply migrations (see
src/lib/db/AGENTS.md). - Hono:
Next.js (App Router): createimport { authRoutes, requireUser, sessionMiddleware } from './lib/auth/adapters/hono.js'; app.use(sessionMiddleware); app.route('/auth', authRoutes()); app.get('/api/me', requireUser, (c) => c.json(c.get('user')));app/auth/login/[provider]/route.tswithexport { loginRoute as GET } from '@/lib/auth/adapters/next', the same forcallback/[provider](callbackRoute) andapp/auth/logout/route.ts(logoutRoute as POST). In Server Components:const user = await getUser(await cookies()). Delete the adapter of the framework you don't use (adapters/hono.tsimportshono). - Login link:
<a href="/auth/login/github?returnTo=/dashboard">. Logout:POST /auth/logout. - Verify: open
/auth/login/github, finish the flow, thenGET /api/mereturns the user.
Conventions
- Read the current user only through
sessionMiddleware/getUser; never parse the cookie yourself. - Other modules reference
users.id(text,usr_…) withonDelete: 'cascade'orset null. - Call
rotateSessionafter a privilege change andinvalidateUserSessionswhen an account is compromised. - If you installed
@core/dbwith--dest, fix the../db/index.jsimports here.
Don't
- Don't store or log session tokens, OAuth codes or
AUTH_SECRET. - Don't link accounts by unverified email;
upsertOAuthUseralready enforces it. - Don't accept absolute
returnToURLs (open redirect); usesafeReturnTo. - Don't make GET requests log users out, and don't disable
HttpOnly/SameSiteon the cookie.
# @core/auth — rules for AI agents
## Purpose
Cookie sessions (random token, only its SHA-256 stored, 30-day sliding expiry, rotation) and OAuth login with GitHub
and Google via `arctic`. Tables: `users`, `auth_sessions`, `oauth_accounts`. No roles or permissions (out of scope:
build them in your app on top of `users.id`). No passwords.
## Map
- `index.ts` — public API: `getUserFromCookieHeader`, `signOut`, `startOAuth`, `finishOAuth`, session functions, `User`.
- `adapters/hono.ts` — `sessionMiddleware`, `requireUser`, `authRoutes()`.
- `adapters/next.ts` — `loginRoute`, `callbackRoute`, `logoutRoute`, `getUser(cookies)`.
- `schema.ts` — Drizzle tables. Depends on `../db` (@core/db).
## Integration
1. Env: `AUTH_SECRET` (≥ 32 random chars, e.g. `openssl rand -base64 32`), `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`.
Optional Google: `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`. Providers without both vars are disabled.
2. OAuth app callback URL: `<origin>/auth/callback/github` (and `/auth/callback/google`).
3. Generate and apply migrations (see `src/lib/db/AGENTS.md`).
4. Hono:
```ts
import { authRoutes, requireUser, sessionMiddleware } from './lib/auth/adapters/hono.js';
app.use(sessionMiddleware);
app.route('/auth', authRoutes());
app.get('/api/me', requireUser, (c) => c.json(c.get('user')));
```
Next.js (App Router): create `app/auth/login/[provider]/route.ts` with `export { loginRoute as GET } from '@/lib/auth/adapters/next'`,
the same for `callback/[provider]` (`callbackRoute`) and `app/auth/logout/route.ts` (`logoutRoute as POST`).
In Server Components: `const user = await getUser(await cookies())`.
Delete the adapter of the framework you don't use (`adapters/hono.ts` imports `hono`).
5. Login link: `<a href="/auth/login/github?returnTo=/dashboard">`. Logout: `POST /auth/logout`.
6. Verify: open `/auth/login/github`, finish the flow, then `GET /api/me` returns the user.
## Conventions
- Read the current user only through `sessionMiddleware`/`getUser`; never parse the cookie yourself.
- Other modules reference `users.id` (text, `usr_…`) with `onDelete: 'cascade'` or `set null`.
- Call `rotateSession` after a privilege change and `invalidateUserSessions` when an account is compromised.
- If you installed `@core/db` with `--dest`, fix the `../db/index.js` imports here.
## Don't
- Don't store or log session tokens, OAuth codes or `AUTH_SECRET`.
- Don't link accounts by unverified email; `upsertOAuthUser` already enforces it.
- Don't accept absolute `returnTo` URLs (open redirect); use `safeReturnTo`.
- Don't make GET requests log users out, and don't disable `HttpOnly`/`SameSite` on the cookie.
The exact tree that will be injected, after .genpmignore. Pinned to
// OAuth con GitHub y Google (arctic). Núcleo sin framework: los adaptadores pasan el origen, la URL de callback y la
// cabecera Cookie, y devuelven las cabeceras Set-Cookie que les damos.
import { hmac } from '@oslojs/crypto/hmac';
import { SHA256 } from '@oslojs/crypto/sha2';
import { decodeBase64urlIgnorePadding, encodeBase64urlNoPadding } from '@oslojs/encoding';
import { decodeIdToken, GitHub, Google, generateCodeVerifier, generateState } from 'arctic';
import { and, eq } from 'drizzle-orm';
import { type Executor, getDb, withTransaction } from '../db/index.js';
import { parseCookies, serializeCookie, sessionCookie } from './cookies.js';
import { oauthAccounts, type User, users } from './schema.js';
import { createSession, generateSessionToken } from './session.js';
export type Provider = 'github' | 'google';
export type AuthErrorCode = 'provider_disabled' | 'invalid_state' | 'oauth_failed' | 'missing_secret';
export class AuthError extends Error {
constructor(
readonly code: AuthErrorCode,
message: string,
) {
super(message);
}
}
const STATE_COOKIE = 'oauth_state';
const STATE_TTL_S = 600;
export function enabledProviders(env: NodeJS.ProcessEnv = process.env): Provider[] {
const out: Provider[] = [];
if (env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET) out.push('github');
if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) out.push('google');
return out;
}
export const callbackPath = (provider: Provider) => `/auth/callback/${provider}`;
function client(provider: Provider, origin: string) {
const env = process.env;
if (!enabledProviders().includes(provider)) throw new AuthError('provider_disabled', `${provider} is not configured`);
const redirect = new URL(callbackPath(provider), origin).toString();
return provider === 'github'
? new GitHub(env.GITHUB_CLIENT_ID!, env.GITHUB_CLIENT_SECRET!, redirect)
: new Google(env.GOOGLE_CLIENT_ID!, env.GOOGLE_CLIENT_SECRET!, redirect);
}
function secret(): Uint8Array {
const s = process.env.AUTH_SECRET;
if (!s || s.length < 32) throw new AuthError('missing_secret', 'AUTH_SECRET must be at least 32 characters');
return new TextEncoder().encode(s);
}
type State = { s: string; v?: string; r: string; p: Provider };
function sign(data: State): string {
const body = encodeBase64urlNoPadding(new TextEncoder().encode(JSON.stringify(data)));
const mac = encodeBase64urlNoPadding(hmac(SHA256, secret(), new TextEncoder().encode(body)));
return `${body}.${mac}`;
}
function verify(value: string | undefined): State | null {
if (!value) return null;
const [body, mac] = value.split('.');
if (!body || !mac) return null;
const expected = encodeBase64urlNoPadding(hmac(SHA256, secret(), new TextEncoder().encode(body)));
if (expected.length !== mac.length) return null;
let diff = 0;
for (let i = 0; i < mac.length; i++) diff |= expected.charCodeAt(i) ^ mac.charCodeAt(i);
if (diff !== 0) return null;
try {
return JSON.parse(new TextDecoder().decode(decodeBase64urlIgnorePadding(body))) as State;
} catch {
return null;
}
}
/** Solo rutas internas: evita redirecciones abiertas (`//evil.com`, `https://…`). */
export function safeReturnTo(value: string | null | undefined): string {
return value && value.startsWith('/') && !value.startsWith('//') && !value.startsWith('/\\') ? value : '/';
}
export function startOAuth(
provider: Provider,
opts: { origin: string; returnTo?: string | null },
): { url: URL; setCookie: string } {
const c = client(provider, opts.origin);
const state = generateState();
const r = safeReturnTo(opts.returnTo);
if (c instanceof GitHub) {
return {
url: c.createAuthorizationURL(state, ['read:user', 'user:email']),
setCookie: serializeCookie(STATE_COOKIE, sign({ s: state, r, p: provider }), STATE_TTL_S),
};
}
const verifier = generateCodeVerifier();
return {
url: c.createAuthorizationURL(state, verifier, ['openid', 'profile', 'email']),
setCookie: serializeCookie(STATE_COOKIE, sign({ s: state, v: verifier, r, p: provider }), STATE_TTL_S),
};
}
export type Profile = { id: string; email: string | null; emailVerified: boolean; name: string | null; avatarUrl: string | null };
async function githubProfile(accessToken: string): Promise<Profile> {
const get = async <T>(path: string): Promise<T> => {
const res = await fetch(`https://api.github.com${path}`, {
headers: { authorization: `Bearer ${accessToken}`, accept: 'application/vnd.github+json', 'user-agent': 'core-auth' },
});
if (!res.ok) throw new AuthError('oauth_failed', `GitHub ${path} ${res.status}`);
return (await res.json()) as T;
};
const u = await get<{ id: number; login: string; name: string | null; avatar_url: string | null }>('/user');
const emails = await get<Array<{ email: string; primary: boolean; verified: boolean }>>('/user/emails');
const primary = emails.find((e) => e.primary && e.verified) ?? null;
return {
id: String(u.id),
email: primary?.email ?? null,
emailVerified: !!primary,
name: u.name ?? u.login,
avatarUrl: u.avatar_url,
};
}
function googleProfile(idToken: string): Profile {
const c = decodeIdToken(idToken) as { sub: string; email?: string; email_verified?: boolean; name?: string; picture?: string };
return {
id: c.sub,
email: c.email ?? null,
emailVerified: c.email_verified === true,
name: c.name ?? null,
avatarUrl: c.picture ?? null,
};
}
/**
* Busca la cuenta OAuth; si no existe, enlaza con el usuario del mismo email **verificado** o crea uno nuevo.
* Nunca enlaza por un email sin verificar (toma de cuentas).
*/
export async function upsertOAuthUser(provider: Provider, p: Profile, db: Executor = getDb()): Promise<User> {
const run = async (tx: Executor) => {
const [linked] = await tx
.select({ user: users })
.from(oauthAccounts)
.innerJoin(users, eq(oauthAccounts.userId, users.id))
.where(and(eq(oauthAccounts.provider, provider), eq(oauthAccounts.providerUserId, p.id)));
if (linked) return linked.user;
let user: User | undefined;
if (p.email && p.emailVerified) [user] = await tx.select().from(users).where(eq(users.email, p.email));
if (!user) {
[user] = await tx
.insert(users)
.values({ email: p.emailVerified ? p.email : null, name: p.name, avatarUrl: p.avatarUrl })
.returning();
}
await tx.insert(oauthAccounts).values({ provider, providerUserId: p.id, userId: user!.id });
return user!;
};
return db === getDb() ? withTransaction(run) : run(db);
}
/** Completa el login: valida el estado, canjea el código, crea/enlaza el usuario y abre una sesión. */
export async function finishOAuth(
provider: Provider,
opts: { url: string | URL; cookieHeader: string | null | undefined },
): Promise<{ user: User; token: string; setCookies: string[]; returnTo: string }> {
const url = new URL(opts.url);
const state = verify(parseCookies(opts.cookieHeader)[STATE_COOKIE]);
const code = url.searchParams.get('code');
if (!state || state.p !== provider || !code || url.searchParams.get('state') !== state.s) {
throw new AuthError('invalid_state', 'OAuth state mismatch: start the login again');
}
const c = client(provider, url.origin);
let profile: Profile;
try {
profile =
c instanceof GitHub
? await githubProfile((await c.validateAuthorizationCode(code)).accessToken())
: googleProfile((await c.validateAuthorizationCode(code, state.v ?? '')).idToken());
} catch (e) {
if (e instanceof AuthError) throw e;
throw new AuthError('oauth_failed', `${provider} rejected the authorization code`);
}
const user = await upsertOAuthUser(provider, profile);
const token = generateSessionToken();
await createSession(token, user.id);
return { user, token, setCookies: [sessionCookie(token), serializeCookie(STATE_COOKIE, '', 0)], returnTo: state.r };
}
This package declares no MCP servers.
| Version | Commit | Published | Scan |
|---|---|---|---|
| 1.0.0 | 12c4918 | 3 hours ago | ✔ scan passed |
- genpm
- @core/db ^1.0.0
- proposed
- GenPM proposes the npm command and runs it only if you say yes.
- scan
- scan passed · 0 findings
- commit
- v1.0.0 → 12c4918f68aa7cb6062a976347bf6a7d691cc218 · verified after fetch
- scripts
- None. GenPM never runs package code.
- license
- MIT
- report
- See something wrong?