Files
classroomio/packages/db/src/auth/token-exchange.ts
T

106 lines
3.0 KiB
TypeScript
Raw Normal View History

2026-03-09 11:18:22 +01:00
import * as schema from '@db/schema';
import { eq } from 'drizzle-orm';
import { jwtVerify } from 'jose';
import { getAllActiveTokenAuth } from '@db/queries/organization/token-auth';
import { ensureOrgMembership } from './hooks/sso-provisioning';
import { db } from '@db/drizzle';
import { ZTokenExchangePayload } from '@cio/utils/validation/organization';
import type { User } from 'better-auth';
const MAX_TOKEN_AGE_SEC = 5 * 60; // 5 minutes
export class TokenExchangeError extends Error {
constructor(
message: string,
public code: string,
public statusCode: number
) {
super(message);
this.name = 'TokenExchangeError';
}
}
/**
* Exchange a JWT token for a user and org. Verifies signature with org's signing secret,
* finds or creates user, ensures org membership. Caller is responsible for creating session and setting cookie.
*/
export async function exchangeToken(
token: string,
authApi: {
signUpEmail: (args: { body: { name: string; email: string; password: string } }) => Promise<{ user: User }>;
}
): Promise<{ user: User; orgId: string }> {
const configs = await getAllActiveTokenAuth();
if (configs.length === 0) {
throw new TokenExchangeError('No active token auth configured', 'TOKEN_EXCHANGE_NOT_ENABLED', 403);
}
let payload: unknown;
let orgId: string | null = null;
for (const config of configs) {
const secret = new TextEncoder().encode(config.signingSecret);
try {
const { payload: verified } = await jwtVerify(token, secret, {
algorithms: ['HS256'],
maxTokenAge: MAX_TOKEN_AGE_SEC
});
payload = verified;
orgId = config.organizationId;
break;
} catch {
continue;
}
}
if (!orgId) {
throw new TokenExchangeError('Invalid token', 'TOKEN_EXCHANGE_INVALID_TOKEN', 400);
}
const parsed = ZTokenExchangePayload.safeParse(payload);
if (!parsed.success) {
throw new TokenExchangeError('Invalid token payload', 'TOKEN_EXCHANGE_INVALID_TOKEN', 400);
}
const { email, name, avatar } = parsed.data;
const emailLower = email.toLowerCase();
const [existingUser] = await db.select().from(schema.user).where(eq(schema.user.email, emailLower)).limit(1);
let user: User;
if (existingUser) {
user = existingUser as User;
} else {
const randomPassword = crypto.randomUUID() + crypto.randomUUID();
const signUpBody: { name: string; email: string; password: string; image?: string } = {
name: name ?? emailLower.split('@')[0],
email: emailLower,
password: randomPassword
};
if (avatar) signUpBody.image = avatar;
const result = await authApi.signUpEmail({
body: signUpBody
});
user = result.user;
}
await ensureOrgMembership(user.id, user.email ?? emailLower, orgId);
if (avatar) {
await db
.update(schema.profile)
.set({ avatarUrl: avatar, updatedAt: new Date().toISOString() })
.where(eq(schema.profile.id, user.id));
}
return {
user: {
...user,
image: user.image ?? null
},
orgId
};
}