325 lines
10 KiB
TypeScript
325 lines
10 KiB
TypeScript
import type { AuthUser, TokenResponse } from '@/types';
|
|
import { setTokens } from '@/lib/api';
|
|
|
|
export const KEYCLOAK_LOGIN_STATE_KEY = 'beaver_keycloak_login_state';
|
|
export const KEYCLOAK_LOGOUT_IN_PROGRESS_KEY = 'beaver_keycloak_logout_in_progress';
|
|
|
|
const DEFAULT_KEYCLOAK_ISSUER = 'https://keycloak.bwgdi.com/realms/beaver';
|
|
const DEFAULT_KEYCLOAK_CLIENT_ID = 'beaver-agnet';
|
|
const DEFAULT_SCOPE = 'openid profile email';
|
|
const LOGOUT_PROGRESS_TTL_MS = 120_000;
|
|
|
|
export type KeycloakLoginState = {
|
|
state: string;
|
|
nonce: string;
|
|
codeVerifier: string;
|
|
redirectUri: string;
|
|
nextPath: string;
|
|
createdAt: number;
|
|
};
|
|
|
|
export type KeycloakCallbackParams = {
|
|
code: string;
|
|
state: string;
|
|
error: string;
|
|
errorDescription: string;
|
|
};
|
|
|
|
type AuthorizeUrlInput = {
|
|
state: string;
|
|
nonce: string;
|
|
codeChallenge: string;
|
|
nextPath?: string;
|
|
};
|
|
|
|
type CallbackExchangeInput = {
|
|
code: string;
|
|
state: KeycloakLoginState;
|
|
};
|
|
|
|
type CallbackExchangeResult = {
|
|
token: TokenResponse;
|
|
user: AuthUser;
|
|
};
|
|
|
|
function isBrowser(): boolean {
|
|
return typeof window !== 'undefined';
|
|
}
|
|
|
|
function cleanBaseUrl(value: string): string {
|
|
return value.trim().replace(/\/+$/, '');
|
|
}
|
|
|
|
function envValue(name: string): string {
|
|
const value = process.env[name]?.trim();
|
|
return value || '';
|
|
}
|
|
|
|
export function getKeycloakIssuer(): string {
|
|
return cleanBaseUrl(envValue('NEXT_PUBLIC_KEYCLOAK_ISSUER') || DEFAULT_KEYCLOAK_ISSUER);
|
|
}
|
|
|
|
export function getKeycloakClientId(): string {
|
|
return envValue('NEXT_PUBLIC_KEYCLOAK_CLIENT_ID') || DEFAULT_KEYCLOAK_CLIENT_ID;
|
|
}
|
|
|
|
export function getKeycloakRedirectUri(): string {
|
|
const configured = envValue('NEXT_PUBLIC_KEYCLOAK_REDIRECT_URI');
|
|
if (configured) return configured;
|
|
if (isBrowser()) return `${window.location.origin}/auth/callback`;
|
|
return '/auth/callback';
|
|
}
|
|
|
|
export function getKeycloakPostLogoutRedirectUri(): string {
|
|
const configured = envValue('NEXT_PUBLIC_KEYCLOAK_POST_LOGOUT_REDIRECT_URI');
|
|
if (configured) return configured;
|
|
if (isBrowser()) return `${window.location.origin}/logout/callback`;
|
|
return '/logout/callback';
|
|
}
|
|
|
|
export function buildKeycloakAuthorizeUrl(input: AuthorizeUrlInput): string {
|
|
const url = new URL(`${getKeycloakIssuer()}/protocol/openid-connect/auth`);
|
|
url.searchParams.set('client_id', getKeycloakClientId());
|
|
url.searchParams.set('response_type', 'code');
|
|
url.searchParams.set('scope', DEFAULT_SCOPE);
|
|
url.searchParams.set('redirect_uri', getKeycloakRedirectUri());
|
|
url.searchParams.set('state', input.state);
|
|
url.searchParams.set('nonce', input.nonce);
|
|
url.searchParams.set('code_challenge', input.codeChallenge);
|
|
url.searchParams.set('code_challenge_method', 'S256');
|
|
return url.toString();
|
|
}
|
|
|
|
export function buildKeycloakLogoutUrl(idToken?: string | null): string {
|
|
const url = new URL(`${getKeycloakIssuer()}/protocol/openid-connect/logout`);
|
|
url.searchParams.set('client_id', getKeycloakClientId());
|
|
url.searchParams.set('post_logout_redirect_uri', getKeycloakPostLogoutRedirectUri());
|
|
if (idToken) {
|
|
url.searchParams.set('id_token_hint', idToken);
|
|
}
|
|
return url.toString();
|
|
}
|
|
|
|
function base64Url(bytes: Uint8Array): string {
|
|
let binary = '';
|
|
bytes.forEach((byte) => {
|
|
binary += String.fromCharCode(byte);
|
|
});
|
|
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
}
|
|
|
|
function rotateRight(value: number, shift: number): number {
|
|
return (value >>> shift) | (value << (32 - shift));
|
|
}
|
|
|
|
function sha256Fallback(input: string): Uint8Array {
|
|
const bytes = new TextEncoder().encode(input);
|
|
const bitLength = bytes.length * 8;
|
|
const paddedLength = (((bytes.length + 9 + 63) >> 6) << 6);
|
|
const padded = new Uint8Array(paddedLength);
|
|
padded.set(bytes);
|
|
padded[bytes.length] = 0x80;
|
|
const view = new DataView(padded.buffer);
|
|
view.setUint32(paddedLength - 4, bitLength, false);
|
|
|
|
const h = [
|
|
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
|
|
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
|
|
];
|
|
const k = [
|
|
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
|
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
|
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
|
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
|
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
|
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
|
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
|
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
|
];
|
|
const w = new Array<number>(64);
|
|
|
|
for (let offset = 0; offset < paddedLength; offset += 64) {
|
|
for (let i = 0; i < 16; i += 1) {
|
|
w[i] = view.getUint32(offset + i * 4, false);
|
|
}
|
|
for (let i = 16; i < 64; i += 1) {
|
|
const s0 = rotateRight(w[i - 15], 7) ^ rotateRight(w[i - 15], 18) ^ (w[i - 15] >>> 3);
|
|
const s1 = rotateRight(w[i - 2], 17) ^ rotateRight(w[i - 2], 19) ^ (w[i - 2] >>> 10);
|
|
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0;
|
|
}
|
|
|
|
let [a, b, c, d, e, f, g, hh] = h;
|
|
for (let i = 0; i < 64; i += 1) {
|
|
const s1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25);
|
|
const ch = (e & f) ^ (~e & g);
|
|
const temp1 = (hh + s1 + ch + k[i] + w[i]) >>> 0;
|
|
const s0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22);
|
|
const maj = (a & b) ^ (a & c) ^ (b & c);
|
|
const temp2 = (s0 + maj) >>> 0;
|
|
hh = g;
|
|
g = f;
|
|
f = e;
|
|
e = (d + temp1) >>> 0;
|
|
d = c;
|
|
c = b;
|
|
b = a;
|
|
a = (temp1 + temp2) >>> 0;
|
|
}
|
|
|
|
h[0] = (h[0] + a) >>> 0;
|
|
h[1] = (h[1] + b) >>> 0;
|
|
h[2] = (h[2] + c) >>> 0;
|
|
h[3] = (h[3] + d) >>> 0;
|
|
h[4] = (h[4] + e) >>> 0;
|
|
h[5] = (h[5] + f) >>> 0;
|
|
h[6] = (h[6] + g) >>> 0;
|
|
h[7] = (h[7] + hh) >>> 0;
|
|
}
|
|
|
|
const digest = new Uint8Array(32);
|
|
const digestView = new DataView(digest.buffer);
|
|
h.forEach((value, index) => digestView.setUint32(index * 4, value, false));
|
|
return digest;
|
|
}
|
|
|
|
export async function createCodeChallenge(codeVerifier: string): Promise<string> {
|
|
const subtle = isBrowser() ? window.crypto?.subtle : globalThis.crypto?.subtle;
|
|
if (subtle) {
|
|
const digest = await subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier));
|
|
return base64Url(new Uint8Array(digest));
|
|
}
|
|
return base64Url(sha256Fallback(codeVerifier));
|
|
}
|
|
|
|
function randomUrlSafe(bytes = 32): string {
|
|
const random = new Uint8Array(bytes);
|
|
const cryptoApi = isBrowser() ? window.crypto : globalThis.crypto;
|
|
if (cryptoApi?.getRandomValues) {
|
|
cryptoApi.getRandomValues(random);
|
|
} else {
|
|
for (let i = 0; i < random.length; i += 1) {
|
|
random[i] = Math.floor(Math.random() * 256);
|
|
}
|
|
}
|
|
return base64Url(random);
|
|
}
|
|
|
|
export function saveLoginState(state: KeycloakLoginState): void {
|
|
if (!isBrowser()) return;
|
|
sessionStorage.setItem(KEYCLOAK_LOGIN_STATE_KEY, JSON.stringify(state));
|
|
}
|
|
|
|
export function loadLoginState(): KeycloakLoginState | null {
|
|
if (!isBrowser()) return null;
|
|
const raw = sessionStorage.getItem(KEYCLOAK_LOGIN_STATE_KEY);
|
|
if (!raw) return null;
|
|
try {
|
|
const parsed = JSON.parse(raw) as Partial<KeycloakLoginState>;
|
|
if (!parsed.state || !parsed.nonce || !parsed.codeVerifier || !parsed.redirectUri) {
|
|
return null;
|
|
}
|
|
return {
|
|
state: parsed.state,
|
|
nonce: parsed.nonce,
|
|
codeVerifier: parsed.codeVerifier,
|
|
redirectUri: parsed.redirectUri,
|
|
nextPath: parsed.nextPath || '/',
|
|
createdAt: Number(parsed.createdAt || Date.now()),
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function clearLoginState(): void {
|
|
if (!isBrowser()) return;
|
|
sessionStorage.removeItem(KEYCLOAK_LOGIN_STATE_KEY);
|
|
}
|
|
|
|
export function markKeycloakLogoutInProgress(): void {
|
|
if (!isBrowser()) return;
|
|
sessionStorage.setItem(KEYCLOAK_LOGOUT_IN_PROGRESS_KEY, String(Date.now()));
|
|
}
|
|
|
|
export function clearKeycloakLogoutInProgress(): void {
|
|
if (!isBrowser()) return;
|
|
sessionStorage.removeItem(KEYCLOAK_LOGOUT_IN_PROGRESS_KEY);
|
|
}
|
|
|
|
export function isKeycloakLogoutInProgress(): boolean {
|
|
if (!isBrowser()) return false;
|
|
const raw = sessionStorage.getItem(KEYCLOAK_LOGOUT_IN_PROGRESS_KEY);
|
|
if (!raw) return false;
|
|
const timestamp = Number(raw);
|
|
if (!Number.isFinite(timestamp) || Date.now() - timestamp > LOGOUT_PROGRESS_TTL_MS) {
|
|
clearKeycloakLogoutInProgress();
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export async function startKeycloakLogin(nextPath = '/'): Promise<void> {
|
|
if (!isBrowser()) return;
|
|
clearKeycloakLogoutInProgress();
|
|
const codeVerifier = randomUrlSafe(64);
|
|
const state = randomUrlSafe(32);
|
|
const nonce = randomUrlSafe(32);
|
|
const codeChallenge = await createCodeChallenge(codeVerifier);
|
|
const loginState: KeycloakLoginState = {
|
|
state,
|
|
nonce,
|
|
codeVerifier,
|
|
redirectUri: getKeycloakRedirectUri(),
|
|
nextPath,
|
|
createdAt: Date.now(),
|
|
};
|
|
saveLoginState(loginState);
|
|
window.location.assign(buildKeycloakAuthorizeUrl({ state, nonce, codeChallenge, nextPath }));
|
|
}
|
|
|
|
export function parseAuthCallbackUrl(): KeycloakCallbackParams {
|
|
const params = isBrowser() ? new URLSearchParams(window.location.search) : new URLSearchParams();
|
|
return {
|
|
code: params.get('code') || '',
|
|
state: params.get('state') || '',
|
|
error: params.get('error') || '',
|
|
errorDescription: params.get('error_description') || '',
|
|
};
|
|
}
|
|
|
|
function getApiBaseUrl(): string {
|
|
const configured = envValue('NEXT_PUBLIC_API_URL');
|
|
if (configured) return configured.replace(/\/+$/, '');
|
|
return '';
|
|
}
|
|
|
|
export async function exchangeKeycloakCallback(input: CallbackExchangeInput): Promise<CallbackExchangeResult> {
|
|
const response = await fetch(`${getApiBaseUrl()}/api/auth/callback`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
code: input.code,
|
|
state: input.state.state,
|
|
code_verifier: input.state.codeVerifier,
|
|
redirect_uri: input.state.redirectUri,
|
|
nonce: input.state.nonce,
|
|
}),
|
|
});
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
throw new Error(`Keycloak callback failed: ${response.status} ${text}`);
|
|
}
|
|
const token = await response.json() as TokenResponse;
|
|
setTokens(token.access_token, token.refresh_token || '', token.id_token || '');
|
|
return {
|
|
token,
|
|
user: {
|
|
id: token.user_id,
|
|
username: token.username,
|
|
email: token.email || '',
|
|
role: token.role || 'owner',
|
|
quota_tier: 'single-user',
|
|
},
|
|
};
|
|
}
|