shadimage
ha-components
blockauthsource: ok · refreshed

Auth Files and Components

A complex component used to setup Auth.

npx shadcn add "https://hacomponents.keshuac.com/r/auth.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Files3 · source cached 10/8/2026, 7:42:13 AM

providers/HomeAssistantProvider.tsx
"use client";

import { type ReactNode, useEffect, useState } from "react";
import { haWebSocket } from "@/lib/haWebsocket";
import { getAccessToken, login, exchangeCodeForToken, ENV } from "@/lib/haAuth";

export function HomeAssistantProvider({ children, useProxy = false }: { children: ReactNode; useProxy?: boolean }) {
    const [ready, setReady] = useState(false);
    const [token, setToken] = useState<string | null>(() => getAccessToken());

    useEffect(() => {
        if (token) return;

        const searchParams = new URLSearchParams(window.location.search);
        const code = searchParams.get("code");

        if (code) {
            exchangeCodeForToken(code)
                .then(() => {
                    setToken(getAccessToken());
                    // Clean Code from URL
                    window.history.replaceState({}, "", window.location.pathname);
                })
                .catch((err) => {
                    console.error("Failed to exchange code for token:", err);
                });
        } else {
            login(); // redirect to HA OAuth2
        }
    }, []);

    useEffect(() => {
        if (!token) return;
        if (useProxy && !ENV.PROXY_URL) {
            console.error("PROXY_URL is required when useProxy is enabled");
            return;
        }

        async function connect() {
            try {
                await haWebSocket.connect(
                    useProxy ? ENV.PROXY_URL! : `${ENV.HA_HOST}:${ENV.HA_PORT}`,
                    token!,
                    useProxy,
                );
                setReady(true);
            } catch (err) {
                console.error("Failed to connect to HA WebSocket:", err);
            }
        }

        connect();
    }, [token, useProxy]);

    if (!ready) {
        return <div>Connecting to Home Assistant...</div>;
    }

    return <>{children}</>;
}
lib/haAuth.ts
/**
 * Home Assistant Authentication Utility
 *
 * Supports OAuth2 token exchange, refresh, persistent storage in localStorage,
 * and an optional long-lived token override (NEXT_PUBLIC_HA_LONG_LIVED_TOKEN).
 */

export const ENV = {
    // @ts-ignore - import.meta may not exist in Next
    HA_HOST: typeof process !== "undefined" ? process.env.NEXT_PUBLIC_HA_URL : import.meta.env.VITE_HA_URL, // e.g. homeassistant.local

    // @ts-ignore - import.meta may not exist in Next
    HA_PORT: typeof process !== "undefined" ? process.env.NEXT_PUBLIC_HA_PORT : import.meta.env.VITE_HA_PORT, // e.g. 8123

    // OAuth2 app credentials (must match Home Assistant config)
    CLIENT_ID:
        // @ts-ignore - import.meta may not exist in Next
        typeof process !== "undefined" ? process.env.NEXT_PUBLIC_HA_CLIENT_ID : import.meta.env.VITE_HA_CLIENT_ID,

    // Optional override: if set, skip OAuth2
    LONG_LIVED_TOKEN:
        typeof process !== "undefined"
            ? process.env.NEXT_PUBLIC_HA_LONG_LIVED_TOKEN
            : // @ts-ignore - import.meta may not exist in Next
              import.meta.env.VITE_HA_LONG_LIVED_TOKEN,

    PROXY_URL:
        typeof process !== "undefined"
            ? process.env.NEXT_PUBLIC_PROXY_URL
            : // @ts-ignore - import.meta may not exist in Next
              import.meta.env.VITE_PROXY_URL,
};

// Validate required environment variables at module load
const requiredEnvVars = [
    { key: "HA_HOST", value: ENV.HA_HOST },
    { key: "HA_PORT", value: ENV.HA_PORT },
    { key: "CLIENT_ID", value: ENV.CLIENT_ID },
];

const missingVars = requiredEnvVars.filter(({ value }) => !value);
if (missingVars.length > 0) {
    const missing = missingVars.map(({ key }) => key).join(", ");
    throw new Error(
        `Missing required environment variables: ${missing}. ` +
            `Please set NEXT_PUBLIC_* or VITE_* prefixed versions.`,
    );
}

const HA_HTTP_URL = `http://${ENV.HA_HOST}:${ENV.HA_PORT}`;

const STORAGE_KEY = "ha_tokens";

export type Tokens = {
    access_token: string;
    refresh_token?: string;
    expires_in?: number; // in seconds
    token_type: string;
    created_at: number; // epoch ms
};

function log(...args: any[]) {
    console.log("[HA AUTH]", ...args);
}

/**
 * Save tokens into localStorage (with created_at timestamp).
 */
export function saveTokens(tokens: any) {
    const data: Tokens = {
        ...tokens,
        created_at: Date.now(),
    };
    localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
    log("Saved tokens to localStorage:", {
        access_token: !!data.access_token,
        refresh_token: !!data.refresh_token,
        expires_in: data.expires_in,
    });
}

/**
 * Load tokens from localStorage.
 */
export function loadTokens(): Tokens | null {
    try {
        const raw = localStorage.getItem(STORAGE_KEY);
        return raw ? (JSON.parse(raw) as Tokens) : null;
    } catch {
        return null;
    }
}

/**
 * Clear stored tokens.
 */
export function clearTokens() {
    localStorage.removeItem(STORAGE_KEY);
    log("Cleared tokens");
}

/**
 * Redirect to Home Assistant OAuth2 login.
 */
export function login() {
    if (ENV.LONG_LIVED_TOKEN) {
        log("Using long-lived token; skipping OAuth login");
        return;
    }
    const authUrl =
        `${HA_HTTP_URL}/auth/authorize?response_type=code` +
        `&client_id=${encodeURIComponent(ENV.CLIENT_ID)}` +
        `&redirect_uri=${encodeURIComponent(window.location.href)}`;

    log("Redirecting to HA login:", authUrl);

    window.location.href = authUrl;
}

/**
 * Exchange authorization code for access + refresh tokens.
 */
export async function exchangeCodeForToken(code: string) {
    log("Exchanging code for token");
    const res = await fetch(`${HA_HTTP_URL}/auth/token`, {
        method: "POST",
        headers: { "Content-Type": "application/x-www-form-urlencoded" },
        body: new URLSearchParams({
            grant_type: "authorization_code",
            code,
            client_id: ENV.CLIENT_ID,
            redirect_uri: window.location.href,
        }),
    });

    const text = await res.text();
    if (!res.ok) {
        log("Token exchange failed:", res.status, text);
        throw new Error(`Token exchange failed: ${res.status}`);
    }
    const data = JSON.parse(text);
    saveTokens(data);
    return data as Tokens;
}

/**
 * Refresh access token using stored refresh token.
 */
export async function refreshAccessToken() {
    const tokens = loadTokens();
    if (!tokens?.refresh_token) throw new Error("No refresh token available");

    log("Refreshing access token...");
    const res = await fetch(`${HA_HTTP_URL}/auth/token`, {
        method: "POST",
        headers: { "Content-Type": "application/x-www-form-urlencoded" },
        body: new URLSearchParams({
            grant_type: "refresh_token",
            refresh_token: tokens.refresh_token,
            client_id: ENV.CLIENT_ID,
        }),
    });

    const text = await res.text();
    if (!res.ok) {
        log("Refresh failed:", res.status, text);
        throw new Error(`Refresh failed: ${res.status}`);
    }
    const data = JSON.parse(text);
    saveTokens(data);
    log("Refresh successful; new access token saved");
    return data as Tokens;
}

/**
 * Get current access token (returns null if expired or missing).
 */
export function getAccessToken(): string | null {
    if (ENV.LONG_LIVED_TOKEN) return ENV.LONG_LIVED_TOKEN;

    const t = loadTokens();
    if (!t) return null;

    if (!t.expires_in) return t.access_token;

    const expiresAt = t.created_at + t.expires_in * 1000;
    if (Date.now() >= expiresAt) {
        log("Access token expired");
        return null;
    }
    return t.access_token;
}

/**
 * Get time (ms) until current access token expires.
 */
export function getTimeToExpiryMs(): number | null {
    const t = loadTokens();
    if (!t?.expires_in) return null;
    return t.created_at + t.expires_in * 1000 - Date.now();
}

/**
 * Force a refresh now (useful for debugging).
 */
export async function forceRefreshNow() {
    return refreshAccessToken();
}
lib/haWebsocket.ts
import { refreshAccessToken, clearTokens, login, getTimeToExpiryMs, ENV } from "@/lib/haAuth";

class HAWebSocket {
    private ws: WebSocket | null = null;
    private msgId = 1;
    private refreshTimer: number | null = null;

    private stateListeners: Map<string, ((state: any) => void)[]> = new Map();

    async connect(baseUrl: string, token: string, useProxy: boolean = false): Promise<void> {
        return new Promise((resolve, reject) => {
            const HA_HOST = ENV.HA_HOST;
            const HA_PORT = ENV.HA_PORT;
            const haWsUrl = `ws://${HA_HOST}:${HA_PORT}/api/websocket`;
            const fullUrl = useProxy ? `${baseUrl}/ha` : haWsUrl;

            console.log("[HA WS] Connecting:", fullUrl);
            this.ws = new WebSocket(fullUrl);

            this.ws.onopen = () => console.log("[HA WS] Opened");

            this.ws.onmessage = async (event) => {
                const msg = JSON.parse(event.data);

                if (msg.type === "auth_required") {
                    this.ws?.send(JSON.stringify({ type: "auth", access_token: token }));
                } else if (msg.type === "auth_ok") {
                    console.log("[HA WS] Authenticated");
                    this.subscribeEvents("state_changed");
                    this.scheduleRefresh(baseUrl, useProxy);
                    resolve();
                } else if (msg.type === "auth_invalid") {
                    console.warn("[HA WS] auth_invalid, trying refresh");
                    try {
                        const newTokens = await refreshAccessToken();
                        const newToken = newTokens.access_token;
                        console.log("[HA WS] Token refreshed, retrying connect");
                        await this.reconnect(baseUrl, newToken, useProxy);
                        resolve();
                    } catch (err) {
                        console.error("[HA WS] Refresh failed; clearing tokens and restarting login");
                        clearTokens();
                        login();
                        reject(err);
                    }
                } else if (msg.type === "event" && msg.event?.event_type === "state_changed") {
                    const entityId = msg.event.data.entity_id;
                    this.notifyStateListeners(entityId, msg.event.data.new_state);
                }
            };

            this.ws.onerror = (err) => {
                console.error("[HA WS] WebSocket error:", err);
                reject(err);
            };

            this.ws.onclose = () => {
                console.warn("[HA WS] Closed");
                this.clearTimers();
            };
        });
    }

    private async reconnect(baseUrl: string, token: string, useProxy: boolean) {
        this.clearTimers();
        try {
            if (this.ws && this.ws.readyState === WebSocket.OPEN) this.ws.close();
        } catch {}
        return this.connect(baseUrl, token, useProxy);
    }

    private scheduleRefresh(baseUrl: string, useProxy: boolean) {
        this.clearRefreshTimer();
        const tte = getTimeToExpiryMs();
        if (tte == null) {
            console.log("[HA WS] Token has no expiry; skipping proactive refresh (likely long-lived)");
            return;
        }
        const refreshIn = Math.max(tte - 5 * 60 * 1000, 10 * 1000); // 5 min before expiry, at least 10s
        console.log("[HA WS] Scheduling refresh in", Math.round(refreshIn / 1000), "s");
        this.refreshTimer = window.setTimeout(async () => {
            try {
                const newTokens = await refreshAccessToken();
                const newToken = newTokens.access_token;
                console.log("[HA WS] Proactive refresh OK; reconnecting WS");
                await this.reconnect(baseUrl, newToken, useProxy);
            } catch (e) {
                console.warn("[HA WS] Proactive refresh failed; restarting auth");
                clearTokens();
                login();
            }
        }, refreshIn);
    }

    private subscribeEvents(eventType: string) {
        const id = ++this.msgId;
        this.ws?.send(
            JSON.stringify({
                id,
                type: "subscribe_events",
                event_type: eventType,
            }),
        );
    }

    private clearRefreshTimer() {
        if (this.refreshTimer) window.clearTimeout(this.refreshTimer);
        this.refreshTimer = null;
    }

    private clearTimers() {
        this.clearRefreshTimer();
    }

    getState(entityId: string): Promise<any> {
        return new Promise((resolve, reject) => {
            const id = ++this.msgId;
            const TIMEOUT_MS = 10000;

            // Reject immediately if WebSocket is not connected
            if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
                reject(new Error("WebSocket is not connected"));
                return;
            }

            // Set timeout to prevent hanging
            const timeoutId = setTimeout(() => {
                this.ws?.removeEventListener("message", handleMessage);
                reject(new Error(`getState timeout for entity: ${entityId}`));
            }, TIMEOUT_MS);

            const handleMessage = (event: MessageEvent) => {
                const msg = JSON.parse(event.data);
                if (msg.id === id && msg.type === "result") {
                    clearTimeout(timeoutId);
                    this.ws?.removeEventListener("message", handleMessage);
                    if (msg.success && Array.isArray(msg.result)) {
                        const entity = msg.result.find((e: any) => e.entity_id === entityId);
                        resolve(entity);
                    } else {
                        resolve(null);
                    }
                }
            };

            this.ws?.addEventListener("message", handleMessage);

            this.ws?.send(
                JSON.stringify({
                    id,
                    type: "get_states",
                }),
            );
        });
    }

    addStateListener(entityId: string, cb: (newState: any) => void) {
        const listeners = this.stateListeners.get(entityId) || [];
        listeners.push(cb);
        this.stateListeners.set(entityId, listeners);
    }

    removeStateListener(entityId: string, cb: (newState: any) => void) {
        let listeners = this.stateListeners.get(entityId) || [];
        listeners = listeners.filter((fn) => fn !== cb);
        this.stateListeners.set(entityId, listeners);
    }

    private notifyStateListeners(entityId: string, newState: any) {
        const listeners = this.stateListeners.get(entityId);
        if (listeners) listeners.forEach((fn) => fn(newState));
    }

    callService(domain: string, service: string, serviceData: any) {
        const id = ++this.msgId;
        this.ws?.send(
            JSON.stringify({
                id,
                type: "call_service",
                domain,
                service,
                service_data: serviceData,
            }),
        );
    }
    callServiceWithResponse(domain: string, service: string, serviceData: Record<string, any> = {}): Promise<any> {
        return new Promise((resolve, reject) => {
            const id = ++this.msgId;

            const handleMessage = (event: MessageEvent) => {
                const msg = JSON.parse(event.data);
                if (msg.id === id && msg.type === "result") {
                    this.ws?.removeEventListener("message", handleMessage);
                    if (msg.success) {
                        // The response will be in msg.result
                        resolve(msg.result);
                    } else {
                        reject(msg.error || new Error("Service call failed"));
                    }
                }
            };

            this.ws?.addEventListener("message", handleMessage);

            // According to HA API, set `return_response: true` to get a payload back.
            this.ws?.send(
                JSON.stringify({
                    id,
                    type: "call_service",
                    domain,
                    service,
                    service_data: serviceData,
                    return_response: true,
                }),
            );
        });
    }
    fireEvent(eventType: string, eventData: Record<string, any> = {}): Promise<void> {
        return new Promise((resolve, reject) => {
            const id = ++this.msgId;

            const handleMessage = (event: MessageEvent) => {
                const msg = JSON.parse(event.data);
                if (msg.id === id && msg.type === "result") {
                    this.ws?.removeEventListener("message", handleMessage);
                    if (msg.success) {
                        resolve();
                    } else {
                        reject(msg.error);
                    }
                }
            };

            this.ws?.addEventListener("message", handleMessage);

            this.ws?.send(
                JSON.stringify({
                    id,
                    type: "fire_event",
                    event_type: eventType,
                    event_data: eventData,
                }),
            );
        });
    }
}

export const haWebSocket = new HAWebSocket();

More from ha-components

Browse all components in this library →