Files
avz-site/api/kimi/auth.ts
T
2026-06-08 23:50:59 -03:00

131 lines
3.8 KiB
TypeScript

import type { Context } from "hono";
import { setCookie } from "hono/cookie";
import * as jose from "jose";
import * as cookie from "cookie";
import { env } from "../lib/env";
import { getSessionCookieOptions } from "../lib/cookies";
import { Session } from "@contracts/constants";
import { Errors } from "@contracts/errors";
import { signSessionToken, verifySessionToken } from "./session";
import { users as kimiUsers } from "./platform";
import { findUserByUnionId, upsertUser } from "../queries/users";
import type { TokenResponse } from "./types";
async function exchangeAuthCode(
code: string,
redirectUri: string,
): Promise<TokenResponse> {
const body = new URLSearchParams({
grant_type: "authorization_code",
code,
client_id: env.appId,
redirect_uri: redirectUri,
client_secret: env.appSecret,
});
const resp = await fetch(`${env.kimiAuthUrl}/api/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
});
if (!resp.ok) {
const text = await resp.text();
throw new Error(`Token exchange failed (${resp.status}): ${text}`);
}
return resp.json() as Promise<TokenResponse>;
}
const jwks = jose.createRemoteJWKSet(
new URL(`${env.kimiAuthUrl}/api/.well-known/jwks.json`),
);
async function verifyAccessToken(
accessToken: string,
): Promise<{ userId: string; clientId: string }> {
const { payload } = await jose.jwtVerify(accessToken, jwks);
const userId = payload.user_id as string;
const clientId = payload.client_id as string;
if (!userId) {
throw new Error("user_id missing from access token");
}
return { userId, clientId };
}
export async function authenticateRequest(headers: Headers) {
const cookies = cookie.parse(headers.get("cookie") || "");
const token = cookies[Session.cookieName];
if (!token) {
console.warn("[auth] No session cookie found in request.");
throw Errors.forbidden("Invalid authentication token.");
}
const claim = await verifySessionToken(token);
if (!claim) {
throw Errors.forbidden("Invalid authentication token.");
}
const user = await findUserByUnionId(claim.unionId);
if (!user) {
throw Errors.forbidden("User not found. Please re-login.");
}
return user;
}
export function createOAuthCallbackHandler() {
return async (c: Context) => {
const code = c.req.query("code");
const state = c.req.query("state");
const error = c.req.query("error");
const errorDescription = c.req.query("error_description");
if (error) {
if (error === "access_denied") {
return c.redirect("/", 302);
}
return c.json(
{ error, error_description: errorDescription },
400,
);
}
if (!code || !state) {
return c.json({ error: "code and state are required" }, 400);
}
try {
const redirectUri = atob(state);
const tokenResp = await exchangeAuthCode(code, redirectUri);
const { userId } = await verifyAccessToken(tokenResp.access_token);
const userProfile = await kimiUsers.getProfile(tokenResp.access_token);
if (!userProfile) {
throw new Error("Failed to fetch user profile from Kimi Open");
}
await upsertUser({
unionId: userId,
name: userProfile.name,
avatar: userProfile.avatar_url,
lastSignInAt: new Date(),
});
const token = await signSessionToken({
unionId: userId,
clientId: env.appId,
});
const cookieOpts = getSessionCookieOptions(c.req.raw.headers);
setCookie(c, Session.cookieName, token, {
...cookieOpts,
maxAge: Session.maxAgeMs / 1000,
});
return c.redirect("/", 302);
} catch (error) {
console.error("[OAuth] Callback failed", error);
return c.json({ error: "OAuth callback failed" }, 500);
}
};
}
export { exchangeAuthCode, verifyAccessToken };