139 lines
4.6 KiB
TypeScript
139 lines
4.6 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;
|
|
accessToken?: string;
|
|
tokenType?: string;
|
|
};
|
|
|
|
const textEncoder = new TextEncoder();
|
|
const textDecoder = new TextDecoder();
|
|
const DEFAULT_COOKIE_CHUNK_SIZE = 3000;
|
|
const DEFAULT_COOKIE_MAX_CHUNKS = 20;
|
|
|
|
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 function chunkCookieValue(value: string, chunkSize = DEFAULT_COOKIE_CHUNK_SIZE): string[] {
|
|
if (value.length <= chunkSize) return [value];
|
|
const chunks: string[] = [];
|
|
for (let index = 0; index < value.length; index += chunkSize) {
|
|
chunks.push(value.slice(index, index + chunkSize));
|
|
}
|
|
return chunks;
|
|
}
|
|
|
|
export function chunkedCookieName(baseName: string, index: number): string {
|
|
return index === 0 ? baseName : `${baseName}.${index}`;
|
|
}
|
|
|
|
export function chunkedCookieNames(baseName: string, maxChunks = DEFAULT_COOKIE_MAX_CHUNKS): string[] {
|
|
return Array.from({ length: maxChunks }, (_, index) => chunkedCookieName(baseName, index));
|
|
}
|
|
|
|
export function readChunkedCookieValue(
|
|
baseName: string,
|
|
getValue: (name: string) => string | undefined,
|
|
maxChunks = DEFAULT_COOKIE_MAX_CHUNKS
|
|
): string | undefined {
|
|
const first = getValue(baseName);
|
|
if (!first) return undefined;
|
|
let value = first;
|
|
for (let index = 1; index < maxChunks; index += 1) {
|
|
const chunk = getValue(chunkedCookieName(baseName, index));
|
|
if (!chunk) break;
|
|
value += chunk;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
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,
|
|
accessToken: typeof session.accessToken === "string" ? session.accessToken : undefined,
|
|
tokenType: typeof session.tokenType === "string" ? session.tokenType : undefined,
|
|
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;
|
|
}
|