skills/auth-lucia/SKILL.md
# Lucia Auth Skill Minimal, secure authentication for Next.js App Router using Oslo (crypto/cookies), Arctic (OAuth), and Drizzle ORM + SQLite. No Auth.js/NextAuth bloat. Follows post-Lucia deprecation patterns from lucia-auth.com. ## When to Use - Adding authentication to a Next.js project - Implementing OAuth (Google, Apple, Instagram) - Managing user sessions (sign-in, sign-out, session validation) - When the user asks about auth, login, or session management - Projects using Drizzle + SQL
npx skillsauth add aussiegingersnap/cursor-skills skills/auth-luciaInstall 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.
Minimal, secure authentication for Next.js App Router using Oslo (crypto/cookies), Arctic (OAuth), and Drizzle ORM + SQLite. No Auth.js/NextAuth bloat. Follows post-Lucia deprecation patterns from lucia-auth.com.
oslo, arctic, drizzle-orm, better-sqlite3 (or @libsql/client for Railway/Turso)users, sessions, accounts tables in Drizzle/SQLite/api/auth/[provider]/callback)npm install oslo arctic drizzle-orm better-sqlite3 nanoid
npm install -D @types/better-sqlite3
Create src/db/schema.ts:
import { sqliteTable, text, integer, uniqueIndex } from 'drizzle-orm/sqlite-core';
export const users = sqliteTable('users', {
id: text('id').primaryKey(), // nanoid or ulid
name: text('name'),
email: text('email').notNull(),
picture: text('picture'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
});
export const sessions = sqliteTable('sessions', {
id: text('id').primaryKey(), // SHA-256 hash of session token
userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
});
export const accounts = sqliteTable('accounts', {
id: text('id').primaryKey(),
userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
provider: text('provider').notNull(), // 'google', 'apple', 'instagram'
providerAccountId: text('provider_account_id').notNull(),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
expiresAt: integer('expires_at', { mode: 'timestamp' }),
}, (t) => ({
providerUnique: uniqueIndex('accounts_provider_unique').on(t.provider, t.providerAccountId),
}));
// Type exports
export type User = typeof users.$inferSelect;
export type Session = typeof sessions.$inferSelect;
Create src/lib/auth.ts:
import { sha256 } from "oslo/crypto";
import { encodeBase64url, encodeHexLowerCase } from "oslo/encoding";
import { cookies } from "next/headers";
import { db } from "@/db";
import { sessions, users } from "@/db/schema";
import { eq } from "drizzle-orm";
// Constants
const SESSION_COOKIE_NAME = "session";
const SESSION_DURATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
const REFRESH_THRESHOLD_MS = 15 * 24 * 60 * 60 * 1000; // 15 days
const isProduction = process.env.NODE_ENV === "production";
// --- Token Generation & Hashing ---
export function generateSessionToken(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return encodeBase64url(bytes);
}
export async function hashToken(token: string): Promise<string> {
return encodeHexLowerCase(await sha256(new TextEncoder().encode(token)));
}
// --- Session Management ---
export async function createSession(userId: string): Promise<{ token: string; expiresAt: Date }> {
const token = generateSessionToken();
const sessionId = await hashToken(token);
const expiresAt = new Date(Date.now() + SESSION_DURATION_MS);
await db.insert(sessions).values({
id: sessionId,
userId,
expiresAt,
});
return { token, expiresAt };
}
export async function validateSession(token: string): Promise<{
session: (typeof sessions.$inferSelect & { user: typeof users.$inferSelect }) | null;
fresh: boolean;
}> {
const sessionId = await hashToken(token);
const result = await db
.select()
.from(sessions)
.innerJoin(users, eq(sessions.userId, users.id))
.where(eq(sessions.id, sessionId))
.get();
if (!result) {
return { session: null, fresh: false };
}
const { sessions: session, users: user } = result;
// Expired - delete and return null
if (session.expiresAt < new Date()) {
await db.delete(sessions).where(eq(sessions.id, sessionId));
return { session: null, fresh: false };
}
// Check if we should extend (sliding window)
const shouldRefresh = session.expiresAt.getTime() - Date.now() < REFRESH_THRESHOLD_MS;
if (shouldRefresh) {
const newExpiry = new Date(Date.now() + SESSION_DURATION_MS);
await db.update(sessions).set({ expiresAt: newExpiry }).where(eq(sessions.id, sessionId));
return { session: { ...session, expiresAt: newExpiry, user }, fresh: true };
}
return { session: { ...session, user }, fresh: false };
}
export async function invalidateSession(token: string): Promise<void> {
const sessionId = await hashToken(token);
await db.delete(sessions).where(eq(sessions.id, sessionId));
}
export async function invalidateAllUserSessions(userId: string): Promise<void> {
await db.delete(sessions).where(eq(sessions.userId, userId));
}
// --- Cookie Management ---
export function setSessionCookie(token: string, expiresAt: Date): void {
cookies().set(SESSION_COOKIE_NAME, token, {
httpOnly: true,
sameSite: "lax",
secure: isProduction,
expires: expiresAt,
path: "/",
});
}
export function deleteSessionCookie(): void {
cookies().set(SESSION_COOKIE_NAME, "", {
httpOnly: true,
sameSite: "lax",
secure: isProduction,
maxAge: 0,
path: "/",
});
}
export function getSessionCookie(): string | undefined {
return cookies().get(SESSION_COOKIE_NAME)?.value;
}
// --- Auth Helper for Server Components/Actions ---
export async function getAuth(): Promise<{
user: typeof users.$inferSelect | null;
session: typeof sessions.$inferSelect | null;
}> {
const token = getSessionCookie();
if (!token) {
return { user: null, session: null };
}
const { session, fresh } = await validateSession(token);
if (!session) {
deleteSessionCookie();
return { user: null, session: null };
}
// Refresh cookie if session was extended
if (fresh) {
setSessionCookie(token, session.expiresAt);
}
return { user: session.user, session };
}
Create src/lib/oauth.ts:
import { Google, Apple, generateState, generateCodeVerifier } from "arctic";
import { cookies } from "next/headers";
// Initialize providers
export const google = new Google(
process.env.GOOGLE_CLIENT_ID!,
process.env.GOOGLE_CLIENT_SECRET!,
process.env.GOOGLE_REDIRECT_URI!
);
export const apple = new Apple(
process.env.APPLE_CLIENT_ID!,
process.env.APPLE_TEAM_ID!,
process.env.APPLE_KEY_ID!,
process.env.APPLE_PRIVATE_KEY!,
process.env.APPLE_REDIRECT_URI!
);
// Cookie names
const STATE_COOKIE = "oauth_state";
const VERIFIER_COOKIE = "oauth_code_verifier";
const COOKIE_MAX_AGE = 60 * 10; // 10 minutes
const isProduction = process.env.NODE_ENV === "production";
// --- State & Verifier Cookies ---
export function setOAuthCookies(state: string, codeVerifier?: string): void {
const options = {
httpOnly: true,
sameSite: "lax" as const,
secure: isProduction,
maxAge: COOKIE_MAX_AGE,
path: "/",
};
cookies().set(STATE_COOKIE, state, options);
if (codeVerifier) {
cookies().set(VERIFIER_COOKIE, codeVerifier, options);
}
}
export function getOAuthCookies(): { state: string | undefined; codeVerifier: string | undefined } {
return {
state: cookies().get(STATE_COOKIE)?.value,
codeVerifier: cookies().get(VERIFIER_COOKIE)?.value,
};
}
export function clearOAuthCookies(): void {
const options = {
httpOnly: true,
sameSite: "lax" as const,
secure: isProduction,
maxAge: 0,
path: "/",
};
cookies().set(STATE_COOKIE, "", options);
cookies().set(VERIFIER_COOKIE, "", options);
}
// --- URL Generators ---
export async function createGoogleAuthURL(): Promise<URL> {
const state = generateState();
const codeVerifier = generateCodeVerifier();
const url = await google.createAuthorizationURL(state, codeVerifier, {
scopes: ["openid", "email", "profile"],
});
setOAuthCookies(state, codeVerifier);
return url;
}
export async function createAppleAuthURL(): Promise<URL> {
const state = generateState();
const url = await apple.createAuthorizationURL(state, {
scopes: ["name", "email"],
});
setOAuthCookies(state);
return url;
}
Create src/app/api/auth/google/route.ts:
import { redirect } from "next/navigation";
import { createGoogleAuthURL } from "@/lib/oauth";
export async function GET(): Promise<Response> {
const url = await createGoogleAuthURL();
return redirect(url.toString());
}
Create src/app/api/auth/google/callback/route.ts:
import { OAuth2RequestError } from "arctic";
import { google, getOAuthCookies, clearOAuthCookies } from "@/lib/oauth";
import { createSession, setSessionCookie } from "@/lib/auth";
import { db } from "@/db";
import { users, accounts } from "@/db/schema";
import { eq, and } from "drizzle-orm";
import { nanoid } from "nanoid";
import { redirect } from "next/navigation";
interface GoogleUser {
sub: string;
name: string;
email: string;
picture: string;
}
export async function GET(request: Request): Promise<Response> {
const url = new URL(request.url);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const { state: storedState, codeVerifier } = getOAuthCookies();
// Validate state
if (!code || !state || !storedState || state !== storedState || !codeVerifier) {
clearOAuthCookies();
return redirect("/login?error=invalid_state");
}
try {
// Exchange code for tokens
const tokens = await google.validateAuthorizationCode(code, codeVerifier);
// Fetch user info
const response = await fetch("https://openidconnect.googleapis.com/v1/userinfo", {
headers: { Authorization: `Bearer ${tokens.accessToken}` },
});
const googleUser: GoogleUser = await response.json();
clearOAuthCookies();
// Check for existing account
const existingAccount = await db
.select()
.from(accounts)
.where(and(eq(accounts.provider, "google"), eq(accounts.providerAccountId, googleUser.sub)))
.get();
let userId: string;
if (existingAccount) {
// Update tokens
await db
.update(accounts)
.set({
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken ?? existingAccount.refreshToken,
expiresAt: tokens.accessTokenExpiresAt,
})
.where(eq(accounts.id, existingAccount.id));
userId = existingAccount.userId;
} else {
// Check if user exists by email
const existingUser = await db
.select()
.from(users)
.where(eq(users.email, googleUser.email))
.get();
if (existingUser) {
userId = existingUser.id;
} else {
// Create new user
userId = nanoid();
await db.insert(users).values({
id: userId,
name: googleUser.name,
email: googleUser.email,
picture: googleUser.picture,
});
}
// Link account
await db.insert(accounts).values({
id: nanoid(),
userId,
provider: "google",
providerAccountId: googleUser.sub,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.accessTokenExpiresAt,
});
}
// Create session
const { token, expiresAt } = await createSession(userId);
setSessionCookie(token, expiresAt);
return redirect("/");
} catch (e) {
clearOAuthCookies();
if (e instanceof OAuth2RequestError) {
console.error("OAuth error:", e.message);
return redirect("/login?error=oauth_error");
}
throw e;
}
}
Create src/app/api/auth/apple/callback/route.ts:
import { OAuth2RequestError } from "arctic";
import { apple, getOAuthCookies, clearOAuthCookies } from "@/lib/oauth";
import { createSession, setSessionCookie } from "@/lib/auth";
import { db } from "@/db";
import { users, accounts } from "@/db/schema";
import { eq, and } from "drizzle-orm";
import { nanoid } from "nanoid";
import { redirect } from "next/navigation";
import { decodeIdToken } from "arctic";
interface AppleIdToken {
sub: string;
email: string;
}
// Apple sends POST request with form data
export async function POST(request: Request): Promise<Response> {
const formData = await request.formData();
const code = formData.get("code") as string;
const state = formData.get("state") as string;
// Apple sends user info only on first auth
const userDataString = formData.get("user") as string | null;
let userName: string | null = null;
if (userDataString) {
try {
const userData = JSON.parse(userDataString);
userName = `${userData.name?.firstName || ""} ${userData.name?.lastName || ""}`.trim() || null;
} catch {
// Ignore parse errors
}
}
const { state: storedState } = getOAuthCookies();
if (!code || !state || !storedState || state !== storedState) {
clearOAuthCookies();
return redirect("/login?error=invalid_state");
}
try {
const tokens = await apple.validateAuthorizationCode(code);
const idToken = decodeIdToken(tokens.idToken) as AppleIdToken;
clearOAuthCookies();
const existingAccount = await db
.select()
.from(accounts)
.where(and(eq(accounts.provider, "apple"), eq(accounts.providerAccountId, idToken.sub)))
.get();
let userId: string;
if (existingAccount) {
await db
.update(accounts)
.set({
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken ?? existingAccount.refreshToken,
})
.where(eq(accounts.id, existingAccount.id));
userId = existingAccount.userId;
} else {
const existingUser = await db
.select()
.from(users)
.where(eq(users.email, idToken.email))
.get();
if (existingUser) {
userId = existingUser.id;
} else {
userId = nanoid();
await db.insert(users).values({
id: userId,
name: userName,
email: idToken.email,
picture: null,
});
}
await db.insert(accounts).values({
id: nanoid(),
userId,
provider: "apple",
providerAccountId: idToken.sub,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
});
}
const { token, expiresAt } = await createSession(userId);
setSessionCookie(token, expiresAt);
return redirect("/");
} catch (e) {
clearOAuthCookies();
if (e instanceof OAuth2RequestError) {
console.error("OAuth error:", e.message);
return redirect("/login?error=oauth_error");
}
throw e;
}
}
Create src/app/actions/auth.ts:
"use server";
import { redirect } from "next/navigation";
import { getSessionCookie, invalidateSession, deleteSessionCookie } from "@/lib/auth";
export async function signOut(): Promise<void> {
const token = getSessionCookie();
if (token) {
await invalidateSession(token);
}
deleteSessionCookie();
redirect("/login");
}
Next.js 16 Breaking Change:
middleware.tshas been replaced byproxy.ts. The proxy runs on the Node.js runtime (not Edge) and the export isproxynotmiddleware.
Create src/proxy.ts for protected routes:
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const protectedRoutes = ["/dashboard", "/settings", "/profile"];
const authRoutes = ["/login", "/signup"];
export function proxy(request: NextRequest): NextResponse {
const sessionCookie = request.cookies.get("session");
const { pathname } = request.nextUrl;
const isProtectedRoute = protectedRoutes.some((route) => pathname.startsWith(route));
const isAuthRoute = authRoutes.some((route) => pathname.startsWith(route));
// Redirect unauthenticated users from protected routes
if (isProtectedRoute && !sessionCookie) {
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("redirect", pathname);
return NextResponse.redirect(loginUrl);
}
// Redirect authenticated users from auth routes
if (isAuthRoute && sessionCookie) {
return NextResponse.redirect(new URL("/", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};
Create src/middleware.ts for protected routes:
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const protectedRoutes = ["/dashboard", "/settings", "/profile"];
const authRoutes = ["/login", "/signup"];
export function middleware(request: NextRequest): NextResponse {
const sessionCookie = request.cookies.get("session");
const { pathname } = request.nextUrl;
const isProtectedRoute = protectedRoutes.some((route) => pathname.startsWith(route));
const isAuthRoute = authRoutes.some((route) => pathname.startsWith(route));
// Redirect unauthenticated users from protected routes
if (isProtectedRoute && !sessionCookie) {
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("redirect", pathname);
return NextResponse.redirect(loginUrl);
}
// Redirect authenticated users from auth routes
if (isAuthRoute && sessionCookie) {
return NextResponse.redirect(new URL("/", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};
Add to .env.local:
# Google OAuth
GOOGLE_CLIENT_ID=your_client_id
GOOGLE_CLIENT_SECRET=your_client_secret
GOOGLE_REDIRECT_URI=http://localhost:3000/api/auth/google/callback
# Apple OAuth (optional)
APPLE_CLIENT_ID=your_client_id
APPLE_TEAM_ID=your_team_id
APPLE_KEY_ID=your_key_id
APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
APPLE_REDIRECT_URI=http://localhost:3000/api/auth/apple/callback
import { getAuth } from "@/lib/auth";
export default async function Dashboard() {
const { user } = await getAuth();
if (!user) {
redirect("/login");
}
return <div>Welcome, {user.name}</div>;
}
"use client";
import { signOut } from "@/app/actions/auth";
export function SignOutButton() {
return (
<form action={signOut}>
<button type="submit">Sign Out</button>
</form>
);
}
export default function LoginPage() {
return (
<div>
<h1>Sign In</h1>
<a href="/api/auth/google">Continue with Google</a>
<a href="/api/auth/apple">Continue with Apple</a>
</div>
);
}
tools
# Versioning Skill Semantic versioning automation based on conventional commits. Automatically manages version bumps, changelogs, and git tags using `standard-version`. ## When to Use - Before releasing a new version - When preparing a deployment - To generate/update CHANGELOG.md - When the user asks about version management - Setting up versioning for a new project ## Prerequisites - Conventional commits enforced (recommended: lefthook) - Node.js project with package.json ## Setup (One-Ti
tools
Theme generation with tweakcn for shadcn/ui and Magic UI animations. Use when setting up project themes, customizing color schemes, adding dark mode, or integrating animated components.
tools
shadcn/studio component library with MCP integration, theme generation, and block patterns. This skill should be used when building UI with shadcn components, selecting dashboard layouts, or generating landing pages. Canonical source for all shadcn-based work.
development
Enforce a precise, minimal design system inspired by Linear, Notion, and Stripe. Use this skill when building dashboards, admin interfaces, or any UI that needs Jony Ive-level precision - clean, modern, minimalist with taste. Every pixel matters.