Files
NianAIGC/lib/auth/session.ts
2026-05-29 15:54:13 +08:00

100 lines
3.2 KiB
TypeScript

export type AuthUser = {
id: string;
subject: string;
username?: string;
displayName: string;
clientId: string;
tenantId?: string;
authorities: string[];
scope: string[];
};
export type AuthSession = {
version: 1;
user: AuthUser;
issuedAt: number;
expiresAt: number;
};
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
export async function createSignedJsonValue(value: unknown, secret: string): Promise<string> {
const payload = bytesToBase64Url(textEncoder.encode(JSON.stringify(value)));
const signature = await signPayload(payload, secret);
return `${payload}.${signature}`;
}
export async function parseSignedJsonValue<T>(value: string | undefined, secret: string): Promise<T | null> {
if (!value) return null;
const [payload, signature, extra] = value.split(".");
if (!payload || !signature || extra !== undefined) return null;
const expected = await signPayload(payload, secret);
if (!constantTimeEqual(signature, expected)) return null;
try {
return JSON.parse(textDecoder.decode(base64UrlToBytes(payload))) as T;
} catch {
return null;
}
}
export async function createSessionCookieValue(session: AuthSession, secret: string): Promise<string> {
return createSignedJsonValue(session, secret);
}
export async function parseSessionCookieValue(
value: string | undefined,
secret: string,
nowSeconds = Math.floor(Date.now() / 1000)
): Promise<AuthSession | null> {
const session = await parseSignedJsonValue<AuthSession>(value, secret);
if (!session || session.version !== 1) return null;
if (!session.user?.id || !session.user.clientId || !session.expiresAt) return null;
if (session.expiresAt <= nowSeconds) return null;
return {
...session,
user: {
...session.user,
authorities: Array.isArray(session.user.authorities) ? session.user.authorities : [],
scope: Array.isArray(session.user.scope) ? session.user.scope : []
}
};
}
async function signPayload(payload: string, secret: string): Promise<string> {
const key = await crypto.subtle.importKey(
"raw",
textEncoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const signature = await crypto.subtle.sign("HMAC", key, textEncoder.encode(payload));
return bytesToBase64Url(new Uint8Array(signature));
}
function constantTimeEqual(left: string, right: string): boolean {
if (left.length !== right.length) return false;
let diff = 0;
for (let index = 0; index < left.length; index += 1) {
diff |= left.charCodeAt(index) ^ right.charCodeAt(index);
}
return diff === 0;
}
function bytesToBase64Url(bytes: Uint8Array): string {
let binary = "";
for (let index = 0; index < bytes.length; index += 0x8000) {
binary += String.fromCharCode(...bytes.slice(index, index + 0x8000));
}
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function base64UrlToBytes(value: string): Uint8Array {
const base64 = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
return bytes;
}