Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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.
118 changes: 116 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,109 @@ export function submitSignInOption(
options,
);
}

/** Callbacks for the SSE event stream. */
export interface ManagedAuthStreamHandlers {
onState: (data: ManagedAuthStateEventData) => void;
onError: (error: ManagedAuthApiError) => void;
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("\n")) {
if (line.startsWith("event: ")) eventType = line.slice(7);
else if (line.startsWith("data: ")) data = line.slice(6);
Comment thread
dcruzeneil2 marked this conversation as resolved.
Outdated
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
Comment thread
dcruzeneil2 marked this conversation as resolved.
Outdated
}
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, 500, 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();
}
19 changes: 19 additions & 0 deletions packages/managed-auth-react/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,25 @@ 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;
Expand Down
Loading
Loading