Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sse-auth-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@onkernel/managed-auth-react": minor
---

Subscribe to managed auth state via the `/auth/connections/{id}/events` SSE endpoint instead of polling `/auth/connections/{id}` every 2s.
120 changes: 118 additions & 2 deletions packages/managed-auth-react/src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import type { ManagedAuthResponse, MFAType } from "./types";
import type {
ManagedAuthResponse,
ManagedAuthStateEventData,
MFAType,
} from "./types";

export type { ManagedAuthStateEventData };

export interface ApiClientOptions {
baseUrl?: string;
Expand All @@ -10,11 +16,13 @@ const DEFAULT_BASE_URL = "https://api.onkernel.com";
export class ManagedAuthApiError extends Error {
public readonly status: number;
public readonly body: string;
constructor(message: string, status: number, body: string) {
public readonly fatal: boolean;
constructor(message: string, status: number, body: string, fatal = false) {
super(message);
this.name = "ManagedAuthApiError";
this.status = status;
this.body = body;
this.fatal = fatal;
}
}

Expand Down Expand Up @@ -156,3 +164,111 @@ export function submitSignInOption(
options,
);
}

/** Callbacks for the SSE event stream. */
export interface ManagedAuthStreamHandlers {
onState: (data: ManagedAuthStateEventData) => void;
onError: (error: ManagedAuthApiError) => void;
/** Fires only on graceful stream end (server closed the connection). Not called after onError. */
onClose: () => void;
}

/**
* Opens an SSE connection to `/auth/connections/{id}/events` and dispatches
* incoming events to the provided handlers. Returns a teardown function that
* aborts the connection.
*
* Uses fetch + ReadableStream instead of EventSource because the endpoint
* requires an Authorization header.
*/
export function streamManagedAuthEvents(
id: string,
jwt: string,
handlers: ManagedAuthStreamHandlers,
options?: ApiClientOptions,
): () => void {
const controller = new AbortController();
const f = getFetch(options);
const url = `${getBaseUrl(options)}/auth/connections/${id}/events`;

(async () => {
const res = await f(url, {
method: "GET",
headers: {
Authorization: `Bearer ${jwt}`,
Accept: "text/event-stream",
},
signal: controller.signal,
});

if (!res.ok) {
const msg = await parseError(res);
handlers.onError(new ManagedAuthApiError(msg, res.status, msg));
return;
}

if (!res.body) {
handlers.onError(new ManagedAuthApiError("No response body", 0, ""));
return;
}

const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";

// Read chunks from the stream and parse SSE frames (delimited by \n\n).
const SEPARATOR = /\r\n\r\n|\r\r|\n\n/;
for (;;) {
const { value, done } = await reader.read();
if (done) break;

buffer += decoder.decode(value, { stream: true });
for (;;) {
const match = SEPARATOR.exec(buffer);
if (!match) break;
const raw = buffer.slice(0, match.index);
buffer = buffer.slice(match.index + match[0].length);

let eventType = "";
let data = "";
for (const line of raw.split(/\r\n|\r|\n/)) {
if (line.startsWith("event: ")) eventType = line.slice(7);
else if (line.startsWith("data: "))
data += (data ? "\n" : "") + line.slice(6);
}
Comment thread
dcruzeneil2 marked this conversation as resolved.

if (eventType === "managed_auth_state" && data) {
try {
const parsed = JSON.parse(data) as ManagedAuthStateEventData;
handlers.onState(parsed);
} catch {
/* malformed JSON — skip */
}
} else if (eventType === "error" && data) {
let message = "Stream error";
try {
const parsed = JSON.parse(data) as {
error?: { message?: string };
};
if (parsed.error?.message) message = parsed.error.message;
} catch {
/* fall through with default message */
}
handlers.onError(new ManagedAuthApiError(message, 0, data, true));
controller.abort();
return;
}
// sse_heartbeat and unknown event types are silently ignored
}
}

handlers.onClose();
})().catch((err: unknown) => {
// AbortError is expected when the caller invokes the teardown function.
if (err instanceof Error && err.name === "AbortError") return;
const message = err instanceof Error ? err.message : "Stream failed";
handlers.onError(new ManagedAuthApiError(message, 0, ""));
});

return () => controller.abort();
}
23 changes: 23 additions & 0 deletions packages/managed-auth-react/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,32 @@ export interface SignInOption {
description?: string | null;
}

export interface ManagedAuthStateEventData {
event: "managed_auth_state";
timestamp: string;
flow_status: FlowStatus;
flow_step: FlowStep;
flow_type?: "LOGIN" | "REAUTH";
discovered_fields?: DiscoveredField[];
mfa_options?: MFAOption[];
sign_in_options?: SignInOption[];
pending_sso_buttons?: SSOButton[];
external_action_message?: string;
website_error?: string;
error_message?: string;
error_code?: string;
post_login_url?: string;
live_view_url?: string;
hosted_url?: string;
}

export interface ManagedAuthResponse {
id: string;
domain: string;
profile_name: string;
flow_status: FlowStatus;
flow_step: FlowStep;
flow_type?: "LOGIN" | "REAUTH" | null;
discovered_fields?: DiscoveredField[] | null;
pending_sso_buttons?: SSOButton[] | null;
mfa_options?: MFAOption[] | null;
Expand All @@ -71,6 +91,9 @@ export interface ManagedAuthResponse {
website_error?: string | null;
error_message?: string | null;
error_code?: string | null;
post_login_url?: string | null;
live_view_url?: string | null;
hosted_url?: string | null;
}

export type UIState =
Expand Down
Loading
Loading