feat: add login link functionality with token generation and validation
This commit is contained in:
@@ -17,6 +17,7 @@
|
|||||||
"db:exercise-backfill": "tsx src/scripts/backfill-exercise-course.ts",
|
"db:exercise-backfill": "tsx src/scripts/backfill-exercise-course.ts",
|
||||||
"migrate:answer-data": "tsx src/scripts/migrate-answer-data.ts",
|
"migrate:answer-data": "tsx src/scripts/migrate-answer-data.ts",
|
||||||
"db:assets-backfill": "tsx src/scripts/backfill-lesson-assets.ts",
|
"db:assets-backfill": "tsx src/scripts/backfill-lesson-assets.ts",
|
||||||
|
"db:login-link": "tsx src/scripts/generate-login-link.ts",
|
||||||
"db:sync-orphaned-profiles": "tsx src/scripts/sync-orphaned-profiles.ts",
|
"db:sync-orphaned-profiles": "tsx src/scripts/sync-orphaned-profiles.ts",
|
||||||
"move-users": "tsx src/scripts/supabase-to-betterauth.ts",
|
"move-users": "tsx src/scripts/supabase-to-betterauth.ts",
|
||||||
"pull": "drizzle-kit pull --config drizzle.config.ts",
|
"pull": "drizzle-kit pull --config drizzle.config.ts",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { createProfileHook } from './auth/hooks/create-profile';
|
|||||||
import { db } from '@db/drizzle';
|
import { db } from '@db/drizzle';
|
||||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
||||||
import { config as emailAndPassword } from './auth/email-password';
|
import { config as emailAndPassword } from './auth/email-password';
|
||||||
|
import { loginLink } from './auth/plugins/login-link';
|
||||||
import { sso } from '@better-auth/sso';
|
import { sso } from '@better-auth/sso';
|
||||||
import { syncUserWithProfile } from './auth/hooks/sync-user';
|
import { syncUserWithProfile } from './auth/hooks/sync-user';
|
||||||
import { tokenExchange } from './auth/plugins/token-exchange';
|
import { tokenExchange } from './auth/plugins/token-exchange';
|
||||||
@@ -83,6 +84,7 @@ export const auth: ReturnType<typeof betterAuth> = betterAuth({
|
|||||||
// OIDC providers are registered dynamically per organization
|
// OIDC providers are registered dynamically per organization
|
||||||
// via the admin API (auth.api.registerSSOProvider)
|
// via the admin API (auth.api.registerSSOProvider)
|
||||||
}),
|
}),
|
||||||
|
loginLink(),
|
||||||
tokenExchange()
|
tokenExchange()
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import * as schema from '@db/schema';
|
||||||
|
|
||||||
|
import { createAuthEndpoint } from 'better-auth/api';
|
||||||
|
import { setSessionCookie as _setSessionCookie } from 'better-auth/cookies';
|
||||||
|
const setSessionCookie = _setSessionCookie as (
|
||||||
|
ctx: unknown,
|
||||||
|
data: { session: Record<string, unknown>; user: Record<string, unknown> }
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
|
import { db } from '@db/drizzle';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { jwtVerify } from 'jose';
|
||||||
|
import { ZLoginLinkPayload, ZLoginLinkQuery } from '@cio/utils/validation/organization';
|
||||||
|
|
||||||
|
const SESSION_EXPIRES_IN_SEC = 60 * 60 * 24 * 7;
|
||||||
|
|
||||||
|
function generateToken(): string {
|
||||||
|
const bytes = new Uint8Array(32);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLoginLinkSecret(): Uint8Array {
|
||||||
|
const secret = process.env.BETTER_AUTH_SECRET?.trim();
|
||||||
|
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error('BETTER_AUTH_SECRET is required for login links');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TextEncoder().encode(secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loginLink() {
|
||||||
|
return {
|
||||||
|
id: 'login-link',
|
||||||
|
endpoints: {
|
||||||
|
loginLink: createAuthEndpoint('/login-link', { method: 'GET' }, async (c) => {
|
||||||
|
const url = new URL(c.request?.url ?? '', 'http://localhost');
|
||||||
|
const query = ZLoginLinkQuery.safeParse({
|
||||||
|
token: url.searchParams.get('token') ?? '',
|
||||||
|
redirect: url.searchParams.get('redirect') ?? undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!query.success) {
|
||||||
|
return c.json({ error: 'Invalid query', message: query.error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectUrl = query.data.redirect && query.data.redirect.startsWith('/') ? query.data.redirect : '/';
|
||||||
|
|
||||||
|
let payload: unknown;
|
||||||
|
try {
|
||||||
|
const verified = await jwtVerify(query.data.token, getLoginLinkSecret(), {
|
||||||
|
algorithms: ['HS256']
|
||||||
|
});
|
||||||
|
payload = verified.payload;
|
||||||
|
} catch {
|
||||||
|
return c.json({ error: 'Invalid or expired login link' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedPayload = ZLoginLinkPayload.safeParse(payload);
|
||||||
|
if (!parsedPayload.success) {
|
||||||
|
return c.json({ error: 'Invalid login link payload' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedEmail = parsedPayload.data.email.toLowerCase().trim();
|
||||||
|
const [user] = await db.select().from(schema.user).where(eq(schema.user.id, parsedPayload.data.sub)).limit(1);
|
||||||
|
|
||||||
|
if (!user || user.email.toLowerCase().trim() !== normalizedEmail) {
|
||||||
|
return c.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const expiresAt = new Date(now.getTime() + SESSION_EXPIRES_IN_SEC * 1000);
|
||||||
|
const sessionToken = generateToken();
|
||||||
|
|
||||||
|
const [sessionRow] = await db
|
||||||
|
.insert(schema.session)
|
||||||
|
.values({
|
||||||
|
userId: user.id,
|
||||||
|
token: sessionToken,
|
||||||
|
expiresAt,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
impersonatedBy: 'login-link'
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!sessionRow) {
|
||||||
|
return c.json({ error: 'Failed to create session' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = {
|
||||||
|
id: sessionRow.id,
|
||||||
|
userId: sessionRow.userId,
|
||||||
|
token: sessionRow.token,
|
||||||
|
expiresAt: sessionRow.expiresAt,
|
||||||
|
createdAt: sessionRow.createdAt,
|
||||||
|
updatedAt: sessionRow.updatedAt,
|
||||||
|
ipAddress: sessionRow.ipAddress ?? undefined,
|
||||||
|
userAgent: sessionRow.userAgent ?? undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
const userPayload = {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
emailVerified: user.emailVerified,
|
||||||
|
image: user.image ?? null,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
updatedAt: user.updatedAt
|
||||||
|
};
|
||||||
|
|
||||||
|
await setSessionCookie(c, { session, user: userPayload });
|
||||||
|
|
||||||
|
return c.redirect(redirectUrl);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
|
||||||
|
import * as schema from '../schema';
|
||||||
|
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { SignJWT } from 'jose';
|
||||||
|
import { db } from '../drizzle';
|
||||||
|
|
||||||
|
const LOGIN_LINK_TTL_MINUTES = 10;
|
||||||
|
|
||||||
|
function getBaseUrl(): string {
|
||||||
|
return process.env.PUBLIC_SERVER_URL?.trim() || 'http://localhost:3002';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSecret(): Uint8Array {
|
||||||
|
const secret = process.env.BETTER_AUTH_SECRET?.trim();
|
||||||
|
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error('BETTER_AUTH_SECRET is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TextEncoder().encode(secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getArgs() {
|
||||||
|
const email = process.argv[2]?.trim().toLowerCase();
|
||||||
|
const redirect = process.argv[3]?.trim() || '/';
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
throw new Error('Usage: pnpm --filter @cio/db db:login-link <email> [redirect-path]');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { email, redirect };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const { email, redirect } = getArgs();
|
||||||
|
|
||||||
|
const [user] = await db.select().from(schema.user).where(eq(schema.user.email, email)).limit(1);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error(`User not found for email: ${email}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await new SignJWT({
|
||||||
|
email,
|
||||||
|
type: 'login-link'
|
||||||
|
})
|
||||||
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
|
.setSubject(user.id)
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime(`${LOGIN_LINK_TTL_MINUTES}m`)
|
||||||
|
.sign(getSecret());
|
||||||
|
|
||||||
|
const url = new URL('/api/auth/login-link', getBaseUrl());
|
||||||
|
url.searchParams.set('token', token);
|
||||||
|
|
||||||
|
if (redirect.startsWith('/')) {
|
||||||
|
url.searchParams.set('redirect', redirect);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(url.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error('generate-login-link error:', error instanceof Error ? error.message : error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user