From 3ffa5b8d2f00597d81e222f90f2d3251f1cf644f Mon Sep 17 00:00:00 2001
From: Rex Raphael
Date: Tue, 10 Nov 2026 08:57:34 -0600
Subject: [PATCH 01/12] feat(api): report the session's active organization on
/v1/me
GET /v1/me now carries active_org_id, the organization the session has
switched into, read from the session or, for a JWT, from the token's
claims. Clients use it to restore the active organization on reload.
---
api/me_active_org_test.go | 47 +++++++++++++++++++++++++++++++++++++++
api/user_handlers.go | 15 +++++++++++++
2 files changed, 62 insertions(+)
create mode 100644 api/me_active_org_test.go
diff --git a/api/me_active_org_test.go b/api/me_active_org_test.go
new file mode 100644
index 00000000..61480fa2
--- /dev/null
+++ b/api/me_active_org_test.go
@@ -0,0 +1,47 @@
+package api_test
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/xraph/authsome/id"
+ "github.com/xraph/authsome/middleware"
+)
+
+// GET /v1/me reports the session's active organization, so a client that
+// reloads knows which organization the session is in without having to
+// remember it.
+func TestGetMe_ActiveOrgID(t *testing.T) {
+ a, eng := newTestAPI(t)
+ handler := withTestKey(a.Handler())
+
+ _, token, _ := signUp(t, eng, "activeorg@test.com", "SecureP@ss123")
+ sess, err := eng.ResolveSessionByToken(context.Background(), token)
+ require.NoError(t, err)
+
+ get := func(ctx context.Context) map[string]any {
+ t.Helper()
+ req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/v1/me", nil)
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+ require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
+ var resp map[string]any
+ require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp))
+ return resp
+ }
+
+ base := middleware.WithUserID(context.Background(), sess.UserID)
+ _, present := get(middleware.WithSession(base, sess))["active_org_id"]
+ assert.False(t, present, "a session without an organization reports none")
+
+ orgID := id.NewOrgID()
+ withOrg := *sess
+ withOrg.OrgID = orgID
+ assert.Equal(t, orgID.String(), get(middleware.WithSession(base, &withOrg))["active_org_id"])
+}
diff --git a/api/user_handlers.go b/api/user_handlers.go
index 8e4e8a93..a258be87 100644
--- a/api/user_handlers.go
+++ b/api/user_handlers.go
@@ -93,6 +93,11 @@ type MeResponse struct {
// live session (Engine.restampUserSessions), so the two agree, and the
// live list is the one a client can rely on straight after a change.
Roles []string `json:"roles"`
+
+ // ActiveOrgID is the organization the session has switched into with
+ // POST /v1/me/switch-org, empty when it hasn't. Clients read it on reload
+ // rather than remembering it themselves.
+ ActiveOrgID string `json:"active_org_id,omitempty"`
}
func (a *API) handleGetMe(ctx forge.Context, _ *GetMeRequest) (*MeResponse, error) {
@@ -108,6 +113,16 @@ func (a *API) handleGetMe(ctx forge.Context, _ *GetMeRequest) (*MeResponse, erro
resp := &MeResponse{User: u}
sess, hasSess := middleware.SessionFrom(ctx.Context())
+ switch {
+ case hasSess && sess != nil && !sess.OrgID.IsNil():
+ resp.ActiveOrgID = sess.OrgID.String()
+ default:
+ // A JWT carries the organization in its claims rather than on a
+ // session row; the auth middleware puts it on the context.
+ if orgID, ok := middleware.OrgIDFrom(ctx.Context()); ok && !orgID.IsNil() {
+ resp.ActiveOrgID = orgID.String()
+ }
+ }
appID := u.AppID
if hasSess && sess != nil && !sess.AppID.IsNil() {
appID = sess.AppID
From 07a15f1e1b62bf923f91ea9df598d02f6a46b833 Mon Sep 17 00:00:00 2001
From: Rex Raphael
Date: Tue, 10 Nov 2026 08:58:42 -0600
Subject: [PATCH 02/12] chore(sdk): regenerate clients for active_org_id
---
.../packages/authsome_core/lib/src/generated/api_types.dart | 4 ++++
sdk/dart/lib/src/types.dart | 4 ++++
sdk/go/types.go | 1 +
sdk/typescript/src/types.ts | 1 +
sdkgen/spec.json | 3 +++
ui/packages/core/src/generated/api-types.ts | 1 +
6 files changed, 14 insertions(+)
diff --git a/flutter/packages/authsome_core/lib/src/generated/api_types.dart b/flutter/packages/authsome_core/lib/src/generated/api_types.dart
index f6fe877b..3778fc90 100644
--- a/flutter/packages/authsome_core/lib/src/generated/api_types.dart
+++ b/flutter/packages/authsome_core/lib/src/generated/api_types.dart
@@ -5340,6 +5340,7 @@ class MarkInvoicePaidRequest {
}
class MeResponse {
+ final String? activeOrgId;
final String appId;
final String? banExpires;
final String? banReason;
@@ -5363,6 +5364,7 @@ class MeResponse {
final String? username;
const MeResponse({
+ this.activeOrgId,
required this.appId,
this.banExpires,
this.banReason,
@@ -5388,6 +5390,7 @@ class MeResponse {
factory MeResponse.fromJson(Map json) {
return MeResponse(
+ activeOrgId: json['active_org_id'] as String?,
appId: json['app_id'] as String,
banExpires: json['ban_expires'] as String?,
banReason: json['ban_reason'] as String?,
@@ -5414,6 +5417,7 @@ class MeResponse {
Map toJson() {
return {
+ if (activeOrgId != null) 'active_org_id': activeOrgId,
'app_id': appId,
if (banExpires != null) 'ban_expires': banExpires,
if (banReason != null) 'ban_reason': banReason,
diff --git a/sdk/dart/lib/src/types.dart b/sdk/dart/lib/src/types.dart
index f6fe877b..3778fc90 100644
--- a/sdk/dart/lib/src/types.dart
+++ b/sdk/dart/lib/src/types.dart
@@ -5340,6 +5340,7 @@ class MarkInvoicePaidRequest {
}
class MeResponse {
+ final String? activeOrgId;
final String appId;
final String? banExpires;
final String? banReason;
@@ -5363,6 +5364,7 @@ class MeResponse {
final String? username;
const MeResponse({
+ this.activeOrgId,
required this.appId,
this.banExpires,
this.banReason,
@@ -5388,6 +5390,7 @@ class MeResponse {
factory MeResponse.fromJson(Map json) {
return MeResponse(
+ activeOrgId: json['active_org_id'] as String?,
appId: json['app_id'] as String,
banExpires: json['ban_expires'] as String?,
banReason: json['ban_reason'] as String?,
@@ -5414,6 +5417,7 @@ class MeResponse {
Map toJson() {
return {
+ if (activeOrgId != null) 'active_org_id': activeOrgId,
'app_id': appId,
if (banExpires != null) 'ban_expires': banExpires,
if (banReason != null) 'ban_reason': banReason,
diff --git a/sdk/go/types.go b/sdk/go/types.go
index 8ffb8443..a4a05ec2 100644
--- a/sdk/go/types.go
+++ b/sdk/go/types.go
@@ -1229,6 +1229,7 @@ type MarkInvoicePaidRequest struct {
// MeResponse represents the MeResponse schema.
type MeResponse struct {
+ ActiveOrgID string `json:"active_org_id,omitempty"`
AppID string `json:"app_id"`
BanExpires string `json:"ban_expires,omitempty"`
BanReason string `json:"ban_reason,omitempty"`
diff --git a/sdk/typescript/src/types.ts b/sdk/typescript/src/types.ts
index 4dc90e58..87719a70 100644
--- a/sdk/typescript/src/types.ts
+++ b/sdk/typescript/src/types.ts
@@ -1080,6 +1080,7 @@ export interface MarkInvoicePaidRequest {
}
export interface MeResponse {
+ active_org_id?: string;
app_id: string;
ban_expires?: string;
ban_reason?: string;
diff --git a/sdkgen/spec.json b/sdkgen/spec.json
index edd2552e..28975021 100644
--- a/sdkgen/spec.json
+++ b/sdkgen/spec.json
@@ -3931,6 +3931,9 @@
},
"MeResponse": {
"properties": {
+ "active_org_id": {
+ "type": "string"
+ },
"app_id": {
"type": "string"
},
diff --git a/ui/packages/core/src/generated/api-types.ts b/ui/packages/core/src/generated/api-types.ts
index 953d1341..1baf682c 100644
--- a/ui/packages/core/src/generated/api-types.ts
+++ b/ui/packages/core/src/generated/api-types.ts
@@ -1080,6 +1080,7 @@ export interface MarkInvoicePaidRequest {
}
export interface MeResponse {
+ active_org_id?: string;
app_id: string;
ban_expires?: string;
ban_reason?: string;
From d3728eaed037b47dc6310afda59a889e26a3b6dc Mon Sep 17 00:00:00 2001
From: Rex Raphael
Date: Tue, 10 Nov 2026 09:02:58 -0600
Subject: [PATCH 03/12] feat(ui-core): add a client plugin contract and track
the active organization
Optional features can now ship as client plugins, the way they ship as
server plugins. Pass plugins: [subscription()] to AuthManager and look
one up with manager.plugin(subscription). A plugin gets a small context:
an authenticated request (token or cookie), the auth state and client
config, whether the server has it installed, the active organization,
and signedIn, signedOut, sessionRefreshed and orgChanged events.
AuthManager also keeps the session's active organization. It's restored
from /v1/me, set by switchOrg (which keeps a reissued token), and
forgotten on sign-out.
---
ui/packages/core/src/auth.ts | 226 +++++++++++++++++++++++++++
ui/packages/core/src/index.ts | 9 ++
ui/packages/core/src/plugins.test.ts | 204 ++++++++++++++++++++++++
ui/packages/core/src/plugins.ts | 110 +++++++++++++
ui/packages/core/src/types.ts | 8 +
5 files changed, 557 insertions(+)
create mode 100644 ui/packages/core/src/plugins.test.ts
create mode 100644 ui/packages/core/src/plugins.ts
diff --git a/ui/packages/core/src/auth.ts b/ui/packages/core/src/auth.ts
index 8d34b852..45ac4974 100644
--- a/ui/packages/core/src/auth.ts
+++ b/ui/packages/core/src/auth.ts
@@ -20,6 +20,22 @@ import type {
TokenStorage,
User,
} from "./types";
+import {
+ PluginNotRegisteredError,
+ pluginKeyName,
+ type AuthEvent,
+ type AuthPlugin,
+ type PluginContext,
+ type PluginKey,
+} from "./plugins";
+
+/** A registered plugin and, once used, what its init built (or threw). */
+interface PluginSlot {
+ plugin: AuthPlugin;
+ built: boolean;
+ api?: unknown;
+ error?: unknown;
+}
/**
* Storage key under which the whole Session (tokens + expiry) is persisted.
@@ -202,6 +218,12 @@ export class AuthManager {
*/
private cookieSession: boolean;
+ private baseURL: string;
+ private fetchFn: typeof globalThis.fetch;
+ private plugins = new Map();
+ private eventListeners = new Map void>>();
+ private activeOrgId: string | null = null;
+
constructor(config: AuthConfig) {
this.cookieSession = config.session === "cookie";
if (this.cookieSession) {
@@ -217,6 +239,14 @@ export class AuthManager {
this.client = new AuthClient(config);
let baseEnd = config.baseURL.length;
while (baseEnd > 0 && config.baseURL[baseEnd - 1] === "/") baseEnd--;
+ this.baseURL = config.baseURL.slice(0, baseEnd);
+ this.fetchFn = config.fetch ?? globalThis.fetch.bind(globalThis);
+ for (const plugin of config.plugins ?? []) {
+ if (this.plugins.has(plugin.name)) {
+ throw new Error(`authsome: the "${plugin.name}" plugin is registered twice`);
+ }
+ this.plugins.set(plugin.name, { plugin, built: false });
+ }
this.configCacheKey = `${CONFIG_KEY}:${JSON.stringify([config.baseURL.slice(0, baseEnd), config.publishableKey ?? ""])}`;
this.refreshLockKey = `authsome:refresh:${config.baseURL}:${config.publishableKey ?? ""}`;
this.storage = config.storage ?? createMemoryStorage();
@@ -772,11 +802,167 @@ export class AuthManager {
}
/** Tear down: clear timers and listeners. */
+ // ── Plugins ───────────────────────────────────────
+
+ /**
+ * A registered plugin's API, built on first use. Pass the plugin's
+ * definition (`manager.plugin(subscription)`) or the instance you
+ * registered. Throws PluginNotRegisteredError when it isn't in the
+ * plugins list, and rethrows whatever the plugin's init threw.
+ */
+ plugin(key: PluginKey): Api {
+ const name = pluginKeyName(key as PluginKey);
+ const slot = this.plugins.get(name);
+ if (!slot) throw new PluginNotRegisteredError(name);
+ if (!slot.built) {
+ slot.built = true;
+ try {
+ slot.api = slot.plugin.init(this.pluginContext(name));
+ } catch (err) {
+ slot.error = err;
+ }
+ }
+ if (slot.error !== undefined) throw slot.error;
+ return slot.api as Api;
+ }
+
+ /** Whether a plugin was added to the plugins list. */
+ hasPlugin(key: PluginKey): boolean {
+ return this.plugins.has(pluginKeyName(key));
+ }
+
+ /**
+ * Listen for a lifecycle event: signedIn, signedOut, sessionRefreshed
+ * (new tokens for the same session) or orgChanged. Returns a function
+ * that stops listening.
+ */
+ on(event: AuthEvent, listener: () => void): () => void {
+ let set = this.eventListeners.get(event);
+ if (!set) {
+ set = new Set();
+ this.eventListeners.set(event, set);
+ }
+ set.add(listener);
+ return () => {
+ set.delete(listener);
+ };
+ }
+
+ // ── Active organization ───────────────────────────
+
+ /**
+ * The organization the session is working in, null when it has none. It
+ * is restored from /v1/me on load, set by switchOrg, and cleared on sign-out.
+ */
+ getActiveOrgId(): string | null {
+ return this.activeOrgId;
+ }
+
+ /**
+ * Switch the session into another organization. On an app that issues
+ * JWTs the server hands back a new token carrying the organization; it
+ * replaces the stored one. Fires orgChanged.
+ */
+ async switchOrg(orgId: string): Promise {
+ if (this.state.status !== "authenticated") {
+ throw new Error("authsome: you have to be signed in to switch organization");
+ }
+ const { user, session } = this.state;
+ const res = await this.client.switchOrg(
+ { org_id: orgId },
+ this.cookieSession ? "" : session.session_token,
+ );
+
+ if (res.session_token && !this.cookieSession) {
+ const next: Session = {
+ ...session,
+ session_token: res.session_token,
+ expires_at: res.expires_at ?? session.expires_at,
+ };
+ await this.persistSession(next);
+ this.setState({ status: "authenticated", user, session: next });
+ this.scheduleRefresh(next);
+ }
+
+ const changed = this.activeOrgId !== (res.org_id ?? orgId);
+ this.activeOrgId = res.org_id ?? orgId;
+ if (changed) this.emit("orgChanged");
+ }
+
destroy(): void {
this.active = false;
this.clearRefreshTimer();
this.listeners.clear();
this.configListeners.clear();
+ this.eventListeners.clear();
+ }
+
+ // ── Plugin context ────────────────────────────────
+
+ private pluginContext(name: string): PluginContext {
+ return {
+ request: (method, path, init) => this.pluginRequest(method, path, init),
+ getState: () => this.state,
+ getClientConfig: () => this.clientConfig,
+ isServerEnabled: () => {
+ const plugins = this.clientConfig?.supported_plugins;
+ return plugins ? plugins.includes(name) : null;
+ },
+ getActiveOrgId: () => this.activeOrgId,
+ on: (event, listener) => this.on(event, listener),
+ };
+ }
+
+ private async pluginRequest(
+ method: string,
+ path: string,
+ init?: {
+ query?: Record;
+ body?: unknown;
+ },
+ ): Promise {
+ const params = new URLSearchParams();
+ for (const [key, value] of Object.entries(init?.query ?? {})) {
+ if (value !== undefined) params.set(key, String(value));
+ }
+ const qs = params.toString();
+
+ const headers: Record = {
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ };
+ const token = this.cookieSession ? null : this.getSessionToken();
+ if (token) headers.Authorization = `Bearer ${token}`;
+ if (this.publishableKey) headers["X-Publishable-Key"] = this.publishableKey;
+
+ const response = await this.fetchFn(`${this.baseURL}${path}${qs ? `?${qs}` : ""}`, {
+ method,
+ headers,
+ body: init?.body !== undefined ? JSON.stringify(init.body) : undefined,
+ });
+ if (!response.ok) {
+ const data = (await response.json().catch(() => null)) as
+ | { error?: string; code?: number; type?: string; [k: string]: unknown }
+ | null;
+ throw new AuthClientError(
+ data?.error ?? `Request failed with status ${response.status}`,
+ data?.code ?? response.status,
+ data?.type,
+ data ?? undefined,
+ );
+ }
+ if (response.status === 204) return undefined as T;
+ return (await response.json()) as T;
+ }
+
+ private emit(event: AuthEvent): void {
+ for (const listener of this.eventListeners.get(event) ?? []) {
+ try {
+ listener();
+ } catch {
+ // A plugin's listener must not break the state machine.
+ }
+ }
}
// ── Internals ─────────────────────────────────────
@@ -911,7 +1097,10 @@ export class AuthManager {
}
private setState(newState: AuthState): void {
+ const prev = this.state;
this.state = newState;
+ const events = this.transitionEvents(prev, newState);
+
for (const listener of this.listeners) {
try {
listener(newState);
@@ -919,6 +1108,43 @@ export class AuthManager {
// Listener errors should not break the state machine.
}
}
+ for (const event of events) this.emit(event);
+ }
+
+ /**
+ * Works out which lifecycle events a state change means, and keeps the
+ * active organization in step: /v1/me reports it on every load, and
+ * leaving the authenticated state forgets it.
+ */
+ private transitionEvents(prev: AuthState, next: AuthState): AuthEvent[] {
+ const wasIn = prev.status === "authenticated";
+ const isIn = next.status === "authenticated";
+ const events: AuthEvent[] = [];
+
+ if (wasIn && !isIn) {
+ this.activeOrgId = null;
+ events.push("signedOut");
+ return events;
+ }
+ if (!isIn) return events;
+
+ const reported =
+ (next.user as User & { active_org_id?: string }).active_org_id ?? null;
+ if (!wasIn) {
+ this.activeOrgId = reported;
+ events.push("signedIn");
+ return events;
+ }
+ // Still signed in. A refresh rotates the refresh token; switching
+ // organization reissues only the access token, so it doesn't count.
+ if (prev.session.refresh_token !== next.session.refresh_token) {
+ events.push("sessionRefreshed");
+ }
+ if (reported !== null && reported !== this.activeOrgId) {
+ this.activeOrgId = reported;
+ events.push("orgChanged");
+ }
+ return events;
}
private handleError(err: unknown): void {
diff --git a/ui/packages/core/src/index.ts b/ui/packages/core/src/index.ts
index 9f6e696e..a5c73eb6 100644
--- a/ui/packages/core/src/index.ts
+++ b/ui/packages/core/src/index.ts
@@ -3,6 +3,15 @@
export * from "./types";
export * from "./client";
export { AuthManager, createLocalStorage, SESSION_STORAGE_KEY } from "./auth";
+export {
+ definePlugin,
+ PluginNotRegisteredError,
+ type AuthEvent,
+ type AuthPlugin,
+ type PluginContext,
+ type PluginDefinition,
+ type PluginKey,
+} from "./plugins";
export {
isSafeRedirect,
safeRedirectTarget,
diff --git a/ui/packages/core/src/plugins.test.ts b/ui/packages/core/src/plugins.test.ts
new file mode 100644
index 00000000..f9681bc9
--- /dev/null
+++ b/ui/packages/core/src/plugins.test.ts
@@ -0,0 +1,204 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { AuthManager, SESSION_STORAGE_KEY } from "./auth";
+import { definePlugin, PluginNotRegisteredError, type PluginContext } from "./plugins";
+import type { AuthConfig, ClientConfig, Session, User } from "./types";
+
+const user = { id: "usr_1", email: "ada@test" } as User;
+const managers: AuthManager[] = [];
+afterEach(() => managers.splice(0).forEach((m) => m.destroy()));
+const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status });
+
+function setup(opts: { authenticated?: boolean; me?: Record; config?: Partial } = {}) {
+ const session: Session = {
+ session_token: "token-1", refresh_token: "refresh-1",
+ expires_at: new Date(Date.now() + 3_600_000).toISOString(),
+ };
+ const store = new Map();
+ if (opts.authenticated) store.set(SESSION_STORAGE_KEY, JSON.stringify(session));
+ const routes = new Map Response | Promise>([
+ ["GET /v1/me", () => json({ ...user, ...opts.me })],
+ ["POST /v1/signout", () => json({ status: "ok" })],
+ ]);
+ const fetch = vi.fn(async (input, init) => {
+ const url = new URL(String(input));
+ const handler = routes.get(`${init?.method ?? "GET"} ${url.pathname}`);
+ if (!handler) throw new Error(`Unexpected route: ${init?.method} ${url.pathname}`);
+ return handler(init);
+ });
+ const manager = new AuthManager({
+ baseURL: "https://api.test", fetch,
+ storage: {
+ getItem: (k) => store.get(k) ?? null,
+ setItem: (k, v) => { store.set(k, v); },
+ removeItem: (k) => { store.delete(k); },
+ },
+ ...opts.config,
+ });
+ managers.push(manager);
+ return { manager, routes, fetch, store, session };
+}
+
+describe("plugins", () => {
+ const counter = definePlugin("counter", (ctx: PluginContext) => ({ ctx, value: 42 }));
+
+ it("returns what init returned, built once and lazily", async () => {
+ const init = vi.fn(() => ({ n: 1 }));
+ const lazy = definePlugin("lazy", init);
+ const { manager } = setup({ config: { plugins: [lazy()] } });
+ expect(init).not.toHaveBeenCalled();
+ const first = manager.plugin(lazy);
+ expect(first).toEqual({ n: 1 });
+ expect(manager.plugin(lazy)).toBe(first);
+ expect(init).toHaveBeenCalledTimes(1);
+ });
+
+ it("finds a plugin by its definition or its instance", () => {
+ const instance = counter();
+ const { manager } = setup({ config: { plugins: [instance] } });
+ expect(manager.plugin(counter).value).toBe(42);
+ expect(manager.plugin(instance).value).toBe(42);
+ expect(manager.hasPlugin(counter)).toBe(true);
+ });
+
+ it("throws a named error for a plugin that wasn't registered", () => {
+ const { manager } = setup();
+ expect(manager.hasPlugin(counter)).toBe(false);
+ expect(() => manager.plugin(counter)).toThrow(PluginNotRegisteredError);
+ expect(() => manager.plugin(counter)).toThrow(/add counter\(\) to the plugins/i);
+ });
+
+ it("refuses the same plugin twice", () => {
+ expect(() => setup({ config: { plugins: [counter(), counter()] } })).toThrow(/registered twice/);
+ });
+
+ it("surfaces an init error on first use, not at construction", () => {
+ const broken = definePlugin("broken", () => {
+ throw new Error("bad config");
+ });
+ const { manager } = setup({ config: { plugins: [broken()] } });
+ expect(() => manager.plugin(broken)).toThrow("bad config");
+ });
+
+ it("passes options from the registration to init", () => {
+ const withOpts = definePlugin("opts", (_ctx, options?: { greeting: string }) => options?.greeting);
+ const { manager } = setup({ config: { plugins: [withOpts({ greeting: "hi" })] } });
+ expect(manager.plugin(withOpts)).toBe("hi");
+ });
+
+ it("reports whether the server has the plugin", () => {
+ const config = { supported_plugins: ["counter"] } as unknown as ClientConfig;
+ const unknown = setup({ config: { plugins: [counter()] } }).manager.plugin(counter).ctx;
+ expect(unknown.isServerEnabled()).toBeNull();
+
+ const known = setup({ config: { plugins: [counter()], initialClientConfig: config } }).manager.plugin(counter).ctx;
+ expect(known.isServerEnabled()).toBe(true);
+
+ const other = definePlugin("other", (ctx) => ctx);
+ const missing = setup({ config: { plugins: [other()], initialClientConfig: config } }).manager.plugin(other);
+ expect(missing.isServerEnabled()).toBe(false);
+ });
+
+ it("sends authenticated requests with the session token and query", async () => {
+ const { manager, routes, fetch } = setup({ authenticated: true, config: { plugins: [counter()], publishableKey: "pk_test" } });
+ routes.set("GET /v1/billing/subscription", () => json({ subscription: null }));
+ await manager.initialize();
+ const ctx = manager.plugin(counter).ctx;
+
+ const body = await ctx.request<{ subscription: null }>("GET", "/v1/billing/subscription", { query: { tenant_id: "org_1", skip: undefined } });
+ expect(body).toEqual({ subscription: null });
+ const [url, init] = fetch.mock.calls.at(-1)!;
+ expect(String(url)).toBe("https://api.test/v1/billing/subscription?tenant_id=org_1");
+ expect((init?.headers as Record).Authorization).toBe("Bearer token-1");
+ expect((init?.headers as Record)["X-Publishable-Key"]).toBe("pk_test");
+ });
+
+ it("sends credentials instead of a token in cookie mode", async () => {
+ const { manager, routes, fetch } = setup({ config: { plugins: [counter()], session: "cookie" } });
+ routes.set("POST /v1/billing/subscription", () => json({ ok: true }));
+ await manager.plugin(counter).ctx.request("POST", "/v1/billing/subscription", { body: { plan: "pro" } });
+ const [, init] = fetch.mock.calls.at(-1)!;
+ expect(init?.credentials).toBe("include");
+ expect((init?.headers as Record).Authorization).toBeUndefined();
+ expect(init?.body).toBe(JSON.stringify({ plan: "pro" }));
+ });
+
+ it("throws AuthClientError with the server's message", async () => {
+ const { manager, routes } = setup({ config: { plugins: [counter()] } });
+ routes.set("GET /v1/thing", () => json({ error: "nope", type: "forbidden" }, 403));
+ await expect(manager.plugin(counter).ctx.request("GET", "/v1/thing")).rejects.toMatchObject({
+ message: "nope", code: 403, type: "forbidden",
+ });
+ });
+});
+
+describe("lifecycle events", () => {
+ it("fires signedIn, sessionRefreshed and signedOut once each", async () => {
+ const { manager, routes } = setup({ authenticated: true });
+ const seen: string[] = [];
+ for (const e of ["signedIn", "signedOut", "sessionRefreshed", "orgChanged"] as const) {
+ manager.on(e, () => seen.push(e));
+ }
+ await manager.initialize();
+ expect(seen).toEqual(["signedIn"]);
+
+ routes.set("POST /v1/refresh", () => json({ session_token: "token-2", refresh_token: "refresh-2", expires_at: new Date(Date.now() + 3_600_000).toISOString() }));
+ await manager.refreshNow();
+ expect(seen).toEqual(["signedIn", "sessionRefreshed"]);
+
+ await manager.signOut();
+ expect(seen).toEqual(["signedIn", "sessionRefreshed", "signedOut"]);
+ });
+
+ it("stops calling a listener once it unsubscribes", async () => {
+ const { manager } = setup({ authenticated: true });
+ const listener = vi.fn();
+ const off = manager.on("signedIn", listener);
+ off();
+ await manager.initialize();
+ expect(listener).not.toHaveBeenCalled();
+ });
+});
+
+describe("active organization", () => {
+ it("restores the active org from /v1/me", async () => {
+ const { manager } = setup({ authenticated: true, me: { active_org_id: "org_1" } });
+ expect(manager.getActiveOrgId()).toBeNull();
+ await manager.initialize();
+ expect(manager.getActiveOrgId()).toBe("org_1");
+ });
+
+ it("switches org, keeps a reissued token and announces it", async () => {
+ const { manager, routes, store } = setup({ authenticated: true });
+ await manager.initialize();
+ const orgChanged = vi.fn();
+ manager.on("orgChanged", orgChanged);
+ routes.set("POST /v1/me/switch-org", (init) => {
+ expect(JSON.parse(String(init?.body))).toEqual({ org_id: "org_2" });
+ expect((init?.headers as Record).Authorization).toBe("Bearer token-1");
+ return json({ session_id: "ses_1", org_id: "org_2", session_token: "token-org2", expires_at: new Date(Date.now() + 3_600_000).toISOString() });
+ });
+
+ await manager.switchOrg("org_2");
+ expect(manager.getActiveOrgId()).toBe("org_2");
+ expect(orgChanged).toHaveBeenCalledTimes(1);
+ expect(manager.getSessionToken()).toBe("token-org2");
+ expect(JSON.parse(store.get(SESSION_STORAGE_KEY)!).session_token).toBe("token-org2");
+ });
+
+ it("refuses to switch when signed out", async () => {
+ const { manager } = setup();
+ await expect(manager.switchOrg("org_2")).rejects.toThrow(/signed in/);
+ });
+
+ it("forgets the org on sign-out and doesn't hand it to the next user", async () => {
+ const { manager, routes } = setup({ authenticated: true, me: { active_org_id: "org_1" } });
+ await manager.initialize();
+ expect(manager.getActiveOrgId()).toBe("org_1");
+ await manager.signOut();
+ expect(manager.getActiveOrgId()).toBeNull();
+
+ routes.set("POST /v1/signin", () => json({ user: { ...user, id: "usr_2" }, session_token: "t", refresh_token: "r", expires_at: new Date(Date.now() + 3_600_000).toISOString() }));
+ await manager.signIn({ email: "grace@test", password: "pw" });
+ expect(manager.getActiveOrgId()).toBeNull();
+ });
+});
diff --git a/ui/packages/core/src/plugins.ts b/ui/packages/core/src/plugins.ts
new file mode 100644
index 00000000..2cadee7d
--- /dev/null
+++ b/ui/packages/core/src/plugins.ts
@@ -0,0 +1,110 @@
+/**
+ * Client plugins: the client side of authsome's server plugins.
+ *
+ * A server plugin is something you opt into with `authsome.WithPlugin(...)`.
+ * Its client half is the same: a package you opt into by adding it to the
+ * `plugins` list of `AuthManager` (or ``), which then gets an
+ * authenticated way to call its routes and hears about sign-in, sign-out,
+ * refreshes and organization switches. Core knows nothing about any
+ * particular plugin.
+ *
+ * ```ts
+ * import { subscription } from "@authsome/plugin-subscription";
+ *
+ * const auth = new AuthManager({ baseURL, plugins: [subscription()] });
+ * const billing = auth.plugin(subscription);
+ * ```
+ */
+
+import type { AuthState, ClientConfig } from "./types";
+
+/** Moments in the session a plugin can react to. */
+export type AuthEvent = "signedIn" | "signedOut" | "sessionRefreshed" | "orgChanged";
+
+/**
+ * What a plugin gets from the manager. Kept deliberately small: once plugin
+ * packages version on their own, every member here is public API.
+ */
+export interface PluginContext {
+ /**
+ * Call the API as the signed-in user: base URL, publishable key and the
+ * session's bearer token (or cookie, in cookie mode) are filled in. Query
+ * values that are undefined are left out. Rejects with AuthClientError.
+ */
+ request(
+ method: string,
+ path: string,
+ init?: {
+ query?: Record;
+ body?: unknown;
+ },
+ ): Promise;
+ /** The current auth state. */
+ getState(): AuthState;
+ /** The client config, null until it has loaded. */
+ getClientConfig(): ClientConfig | null;
+ /**
+ * Whether the server has this plugin installed, from the client config's
+ * supported_plugins. null while that isn't known: no publishable key, or
+ * the config hasn't loaded yet.
+ */
+ isServerEnabled(): boolean | null;
+ /** The session's active organization, null when it has none. */
+ getActiveOrgId(): string | null;
+ /** Listen for a lifecycle event. Returns a function that stops listening. */
+ on(event: AuthEvent, listener: () => void): () => void;
+}
+
+/** A registered plugin: a name matching the server plugin's, and its init. */
+export interface AuthPlugin {
+ /** The server plugin's Name(), e.g. "subscription". */
+ readonly name: string;
+ /** Builds the plugin's API. Runs once, the first time the plugin is used. */
+ init(ctx: PluginContext): Api;
+}
+
+/**
+ * What definePlugin returns: call it to register the plugin (with options,
+ * if it takes any), and pass it to `manager.plugin(...)` to look it up.
+ */
+export interface PluginDefinition {
+ (options?: Options): AuthPlugin;
+ readonly pluginName: string;
+}
+
+/** Either a definition or a registered instance; both identify a plugin. */
+export type PluginKey =
+ | AuthPlugin
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- the lookup only reads pluginName
+ | PluginDefinition;
+
+/**
+ * Defines a client plugin. `name` must be the server plugin's name, so the
+ * plugin can tell from the client config whether the server side is there.
+ */
+export function definePlugin(
+ name: string,
+ init: (ctx: PluginContext, options: Options | undefined) => Api,
+): PluginDefinition {
+ const define = (options?: Options): AuthPlugin => ({
+ name,
+ init: (ctx) => init(ctx, options),
+ });
+ return Object.assign(define, { pluginName: name });
+}
+
+/** Thrown when code asks for a plugin that wasn't added to the plugins list. */
+export class PluginNotRegisteredError extends Error {
+ constructor(readonly pluginName: string) {
+ super(
+ `authsome: the "${pluginName}" plugin isn't registered. ` +
+ `Add ${pluginName}() to the plugins of your AuthManager or .`,
+ );
+ this.name = "PluginNotRegisteredError";
+ }
+}
+
+/** The name a key refers to. */
+export function pluginKeyName(key: PluginKey): string {
+ return "pluginName" in key ? key.pluginName : key.name;
+}
diff --git a/ui/packages/core/src/types.ts b/ui/packages/core/src/types.ts
index 78319e09..1e16bae4 100644
--- a/ui/packages/core/src/types.ts
+++ b/ui/packages/core/src/types.ts
@@ -1,6 +1,7 @@
/** Core type definitions for AuthSome UI Kit. */
import type { User } from "./generated/api-types";
+import type { AuthPlugin } from "./plugins";
// ── Re-export API types from generated code ──────────
//
@@ -132,6 +133,13 @@ export interface AuthConfig {
*/
session?: "token" | "cookie";
+ /**
+ * Client plugins to load, the client half of the server plugins you
+ * installed, e.g. `[subscription()]`. Look one up with
+ * `manager.plugin(subscription)`.
+ */
+ plugins?: AuthPlugin[];
+
/** Callback invoked when the auth state changes. */
onStateChange?: (state: AuthState) => void;
From 4560563d9d84df640d2a02766b22b03d516c3f9a Mon Sep 17 00:00:00 2001
From: Rex Raphael
Date: Tue, 10 Nov 2026 09:06:27 -0600
Subject: [PATCH 04/12] feat(ui-react)!: add usePlugin and useActiveOrg; make
Protect fail closed
usePlugin(subscription) returns a registered client plugin's API, and
useActiveOrg() the session's organization with a switchOrg that
re-renders on change. AuthProvider takes plugins through its config.
BREAKING CHANGE: and used to render
their children for everyone. role now checks the roles /v1/me returns.
permission can't be checked on the client, so without a condition that
checks it Protect shows the fallback and warns once. Pass a condition to
keep a permission-gated Protect working.
---
ui/packages/react/src/components.tsx | 50 ++++++----
ui/packages/react/src/hooks.ts | 65 ++++++++++++-
ui/packages/react/src/index.ts | 6 +-
ui/packages/react/src/plugins.test.tsx | 122 +++++++++++++++++++++++++
4 files changed, 223 insertions(+), 20 deletions(-)
create mode 100644 ui/packages/react/src/plugins.test.tsx
diff --git a/ui/packages/react/src/components.tsx b/ui/packages/react/src/components.tsx
index b56a67dd..f3349c30 100644
--- a/ui/packages/react/src/components.tsx
+++ b/ui/packages/react/src/components.tsx
@@ -1,6 +1,6 @@
/** Headless UI components for common auth flows. */
-import { useCallback, useState, type FormEvent, type ReactNode } from "react";
+import { useCallback, useEffect, useState, type FormEvent, type ReactNode } from "react";
import { useAuth, type AuthContextValue } from "./context";
// ── Sign In ─────────────────────────────────────────
@@ -219,17 +219,35 @@ export interface ProtectProps {
children: ReactNode;
/** Fallback rendered when the user doesn't meet the requirements. */
fallback?: ReactNode;
- /** Require a specific role (e.g. "admin"). */
+ /** Require an app role the user holds (e.g. "admin"), from /v1/me's roles. */
role?: string;
- /** Require a specific permission (e.g. "packages:write"). */
+ /**
+ * Require a permission (e.g. "packages:write"). The client can't check a
+ * permission on its own, so this needs a `condition` that does; without
+ * one, Protect shows the fallback.
+ */
permission?: string;
/** Custom condition receiving the auth context. */
condition?: (auth: AuthContextValue) => boolean;
}
+let warnedPermission = false;
+
+/** Warns once per page that a permission-only Protect can't be satisfied. */
+function warnUncheckedPermission(permission: string): void {
+ if (warnedPermission) return;
+ warnedPermission = true;
+ console.warn(
+ `authsome: needs a condition that checks the permission. ` +
+ "The client can't check one by itself, so it shows the fallback.",
+ );
+}
+
/**
* Renders children only when the user is authenticated AND meets
- * the specified role, permission, or custom condition.
+ * the specified role, permission, or custom condition. Anything it can't
+ * check, it treats as not met: a guard that guesses would show protected
+ * UI to the wrong people.
*
* Without role/permission/condition, behaves like ``.
*
@@ -247,24 +265,22 @@ export function Protect({
condition,
}: ProtectProps) {
const auth = useAuth();
+ const unchecked = Boolean(permission) && !condition;
+ useEffect(() => {
+ if (unchecked && permission) warnUncheckedPermission(permission);
+ }, [unchecked, permission]);
if (auth.isLoading) return null;
if (!auth.isAuthenticated) return <>{fallback}>;
- // Custom condition check
- if (condition && !condition(auth)) return <>{fallback}>;
-
- // Role/permission checks are left to the consumer's RBAC layer
- // since the auth context doesn't carry roles directly. The
- // `condition` prop covers this use case generically.
- if (role || permission) {
- // These are checked via condition or by a higher-level wrapper
- // that injects role/permission data.
- if (condition === undefined) {
- // No condition provided but role/permission requested — pass through
- // (the consumer should wrap with their own RBAC provider).
- }
+ if (role) {
+ const roles = (auth.user as { roles?: string[] } | null)?.roles ?? [];
+ if (!roles.includes(role)) return <>{fallback}>;
}
+ if (unchecked) return <>{fallback}>;
+
+ if (condition && !condition(auth)) return <>{fallback}>;
+
return <>{children}>;
}
diff --git a/ui/packages/react/src/hooks.ts b/ui/packages/react/src/hooks.ts
index b9e0b82c..b4c4c2e8 100644
--- a/ui/packages/react/src/hooks.ts
+++ b/ui/packages/react/src/hooks.ts
@@ -1,8 +1,8 @@
/** Convenience hooks built on top of useAuth. */
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { readOrganizationList } from "@authsome/ui-core";
-import type { ClientConfig, Organization, OrganizationList, User } from "@authsome/ui-core";
+import type { ClientConfig, Organization, OrganizationList, PluginKey, User } from "@authsome/ui-core";
import { useAuth } from "./context";
/**
@@ -116,6 +116,67 @@ export function useOrganizations(): {
};
}
+/**
+ * usePlugin returns a client plugin's API. The plugin has to be in the
+ * `plugins` of the surrounding ``; if it isn't, this throws an
+ * error that says which one to add.
+ *
+ * ```tsx
+ * ...
+ * const billing = usePlugin(subscription);
+ * ```
+ */
+export function usePlugin(plugin: PluginKey): Api {
+ const { manager } = useAuth();
+ return manager.plugin(plugin);
+}
+
+/**
+ * useActiveOrg returns the session's active organization and a way to
+ * switch it. It re-renders when the organization changes, including on
+ * sign-in (restored from the session) and sign-out (cleared).
+ *
+ * ```tsx
+ * const { orgId, switchOrg } = useActiveOrg();
+ * ```
+ */
+export function useActiveOrg(): {
+ orgId: string | null;
+ switchOrg: (orgId: string) => Promise;
+ isSwitching: boolean;
+} {
+ const { manager } = useAuth();
+ const subscribe = useCallback(
+ (onChange: () => void) => {
+ const offs = (["orgChanged", "signedIn", "signedOut"] as const).map((e) =>
+ manager.on(e, onChange),
+ );
+ return () => offs.forEach((off) => off());
+ },
+ [manager],
+ );
+ const orgId = useSyncExternalStore(
+ subscribe,
+ () => manager.getActiveOrgId(),
+ () => manager.getActiveOrgId(),
+ );
+
+ const [isSwitching, setIsSwitching] = useState(false);
+ const switchOrg = useCallback(
+ async (next: string) => {
+ setIsSwitching(true);
+ try {
+ await manager.switchOrg(next);
+ } finally {
+ setIsSwitching(false);
+ }
+ },
+ [manager],
+ );
+
+ return { orgId, switchOrg, isSwitching };
+}
+
/**
* useSessionToken returns the current session token (or null).
*
diff --git a/ui/packages/react/src/index.ts b/ui/packages/react/src/index.ts
index 4b52d5c5..b6e25c56 100644
--- a/ui/packages/react/src/index.ts
+++ b/ui/packages/react/src/index.ts
@@ -17,11 +17,15 @@ export type {
MFAEnrollment,
APIError,
TokenStorage,
+ AuthPlugin,
+ AuthEvent,
+ PluginContext,
+ PluginKey,
} from "@authsome/ui-core";
// Context + hooks
export { AuthProvider, useAuth, AuthContext, type AuthProviderProps, type AuthContextValue } from "./context";
-export { useUser, useOrganizations, useSessionToken, useClientConfig } from "./hooks";
+export { useUser, useOrganizations, useSessionToken, useClientConfig, usePlugin, useActiveOrg } from "./hooks";
// Headless components
export {
diff --git a/ui/packages/react/src/plugins.test.tsx b/ui/packages/react/src/plugins.test.tsx
new file mode 100644
index 00000000..ec03ca85
--- /dev/null
+++ b/ui/packages/react/src/plugins.test.tsx
@@ -0,0 +1,122 @@
+import { definePlugin } from "@authsome/ui-core";
+import type { AuthPlugin } from "@authsome/ui-core";
+import { act, render, screen, waitFor } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { describe, expect, it, vi } from "vitest";
+
+import { Protect } from "./components";
+import { AuthProvider } from "./context";
+import { useActiveOrg, usePlugin } from "./hooks";
+
+const BASE = "https://api.example.test";
+
+function json(body: unknown) {
+ return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } });
+}
+
+function harness(opts: { me?: Record; plugins?: AuthPlugin[] } = {}) {
+ const store = new Map([[
+ "authsome:session",
+ JSON.stringify({ session_token: "tok", refresh_token: "refresh", expires_at: new Date(Date.now() + 3_600_000).toISOString() }),
+ ]]);
+ const fetchFn = (async (url: string | URL, init?: RequestInit) => {
+ const path = new URL(String(url)).pathname;
+ if (path === "/v1/me") return json({ id: "usr_1", email: "a@test", roles: ["member"], ...opts.me });
+ if (path === "/v1/me/switch-org") {
+ const { org_id } = JSON.parse(String(init?.body));
+ return json({ session_id: "ses_1", org_id });
+ }
+ return new Response("{}", { status: 404 });
+ }) as unknown as typeof fetch;
+
+ const wrap = (children: ReactNode) => (
+ store.get(k) ?? null,
+ setItem: (k, v) => void store.set(k, v),
+ removeItem: (k) => void store.delete(k),
+ }}>
+ {children}
+
+ );
+
+ return {
+ async mount(node: ReactNode) {
+ let out!: ReturnType;
+ await act(async () => { out = render(wrap(node)); });
+ return out;
+ },
+ };
+}
+
+const greeter = definePlugin("greeter", () => ({ hello: () => "hi" }));
+
+describe("usePlugin", () => {
+ it("returns the registered plugin's API", async () => {
+ let seen: ReturnType string }>> | null = null;
+ function Probe() {
+ seen = usePlugin(greeter);
+ return null;
+ }
+ await harness({ plugins: [greeter()] }).mount();
+ expect(seen!.hello()).toBe("hi");
+ });
+
+ it("throws a helpful error when the plugin isn't registered", async () => {
+ const spy = vi.spyOn(console, "error").mockImplementation(() => {});
+ function Probe() {
+ usePlugin(greeter);
+ return null;
+ }
+ await expect(harness().mount()).rejects.toThrow(/greeter\(\)/);
+ spy.mockRestore();
+ });
+});
+
+describe("useActiveOrg", () => {
+ it("reports the restored org and re-renders on a switch", async () => {
+ let seen = null as unknown as ReturnType;
+ function Probe() {
+ seen = useActiveOrg();
+ return {seen.orgId ?? "none"};
+ }
+ await harness({ me: { active_org_id: "org_1" } }).mount();
+ await waitFor(() => expect(screen.getByTestId("org").textContent).toBe("org_1"));
+
+ await act(async () => { await seen.switchOrg("org_2"); });
+ expect(screen.getByTestId("org").textContent).toBe("org_2");
+ expect(seen.isSwitching).toBe(false);
+ });
+});
+
+describe("Protect", () => {
+ it("shows children only to holders of the role", async () => {
+ const h = harness({ me: { roles: ["admin"] } });
+ await h.mount(
+ <>
+ no-admin}>admin-panel
+ no-owner}>owner-panel
+ >,
+ );
+ await waitFor(() => expect(screen.queryByText("admin-panel")).not.toBeNull());
+ expect(screen.queryByText("owner-panel")).toBeNull();
+ expect(screen.queryByText("no-owner")).not.toBeNull();
+ });
+
+ it("fails closed for a permission it can't check", async () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ await harness({ me: { roles: ["admin"] } }).mount(
+ hidden}>secret,
+ );
+ await waitFor(() => expect(screen.queryByText("hidden")).not.toBeNull());
+ expect(screen.queryByText("secret")).toBeNull();
+ expect(warn).toHaveBeenCalledWith(expect.stringMatching(/permission/));
+ warn.mockRestore();
+ });
+
+ it("lets a condition decide a permission", async () => {
+ await harness().mount(
+ true} fallback={hidden}>shown,
+ );
+ await waitFor(() => expect(screen.queryByText("shown")).not.toBeNull());
+ });
+});
From 99c9071b0b1c13fca14cea64584add368d5e2f74 Mon Sep 17 00:00:00 2001
From: Rex Raphael
Date: Tue, 10 Nov 2026 09:17:32 -0600
Subject: [PATCH 05/12] feat(authsome_core): add a client plugin contract and
track the active organization
The Dart side of the ui-core change. Pass plugins: [SomePlugin()] in
AuthConfig and look one up with manager.plugin(). A plugin gets
a PluginContext with an authenticated request, the auth state, the
client config, whether the server has it installed, the active
organization and an events stream.
AuthManager keeps the session's active organization from /v1/me,
switchOrg changes it (keeping a reissued token), and sign-out clears it.
AuthSomeClient gains send(), the raw request plugins use.
---
.../authsome_core/lib/authsome_core.dart | 1 +
.../authsome_core/lib/src/auth_manager.dart | 199 +++++++++++++++-
.../authsome_core/lib/src/client.dart | 48 ++++
.../authsome_core/lib/src/plugins.dart | 88 +++++++
.../packages/authsome_core/lib/src/types.dart | 8 +
.../authsome_core/test/plugins_test.dart | 223 ++++++++++++++++++
6 files changed, 565 insertions(+), 2 deletions(-)
create mode 100644 flutter/packages/authsome_core/lib/src/plugins.dart
create mode 100644 flutter/packages/authsome_core/test/plugins_test.dart
diff --git a/flutter/packages/authsome_core/lib/authsome_core.dart b/flutter/packages/authsome_core/lib/authsome_core.dart
index 23b84c6e..fa97b4b0 100644
--- a/flutter/packages/authsome_core/lib/authsome_core.dart
+++ b/flutter/packages/authsome_core/lib/authsome_core.dart
@@ -12,6 +12,7 @@ export 'src/types.dart';
export 'src/client.dart';
export 'src/auth_manager.dart';
export 'src/exceptions.dart';
+export 'src/plugins.dart';
export 'src/passkey_authenticator.dart';
export 'src/webauthn.dart' show prepareRequestOptions, prepareCreationOptions, serializeAssertion;
export 'src/generated/api_client.dart'
diff --git a/flutter/packages/authsome_core/lib/src/auth_manager.dart b/flutter/packages/authsome_core/lib/src/auth_manager.dart
index 173c7335..393b7cb7 100644
--- a/flutter/packages/authsome_core/lib/src/auth_manager.dart
+++ b/flutter/packages/authsome_core/lib/src/auth_manager.dart
@@ -15,6 +15,7 @@ import 'package:meta/meta.dart';
import 'client.dart';
import 'exceptions.dart';
import 'passkey_authenticator.dart';
+import 'plugins.dart';
import 'types.dart';
import 'webauthn.dart';
@@ -52,6 +53,15 @@ class AuthManager {
/// the UI without callers having to pass it again.
String? _pendingEmail;
+ final Map _plugins = {};
+ final StreamController _events =
+ StreamController.broadcast(sync: true);
+ String? _activeOrgId;
+
+ /// The active organization /v1/me reported on the last profile load. The
+ /// next transition to authenticated consumes it.
+ String? _reportedOrgId;
+
AuthManager(AuthConfig config)
: _client = AuthSomeClient(
AuthClientConfig(
@@ -70,6 +80,7 @@ class AuthManager {
if (config.onStateChange != null) {
_listeners.add(config.onStateChange!);
}
+ _registerPlugins(config.plugins);
}
/// Test-only constructor that accepts a pre-built [AuthSomeClient].
@@ -83,6 +94,7 @@ class AuthManager {
ClientConfig? initialClientConfig,
void Function(String, int?)? onError,
void Function(AuthState)? onStateChange,
+ List> plugins = const [],
}) : _client = client,
_storage = storage ?? MemoryTokenStorage(),
_onError = onError,
@@ -93,6 +105,102 @@ class AuthManager {
if (onStateChange != null) {
_listeners.add(onStateChange);
}
+ _registerPlugins(plugins);
+ }
+
+ void _registerPlugins(List> plugins) {
+ for (final plugin in plugins) {
+ if (_plugins.containsKey(plugin.name)) {
+ throw ArgumentError.value(
+ plugin.name, 'plugins', 'the "${plugin.name}" plugin is registered twice');
+ }
+ _plugins[plugin.name] = _PluginSlot(plugin);
+ }
+ }
+
+ // ── Plugins ───────────────────────────────────────
+
+ /// The API of the registered plugin that provides [Api], built on first
+ /// use: `auth.plugin()`. Throws
+ /// [PluginNotRegisteredError] when no registered plugin provides it, and
+ /// rethrows whatever the plugin's init threw.
+ Api plugin() {
+ final slot = _slotFor();
+ if (slot == null) throw PluginNotRegisteredError(Api);
+ if (!slot.built) {
+ slot.built = true;
+ try {
+ slot.api = slot.plugin.init(_PluginContextImpl(this, slot.plugin.name));
+ } catch (error, stack) {
+ slot.error = error;
+ slot.stack = stack;
+ }
+ }
+ if (slot.error != null) {
+ Error.throwWithStackTrace(slot.error!, slot.stack ?? StackTrace.current);
+ }
+ return slot.api! as Api;
+ }
+
+ /// Whether a registered plugin provides [Api].
+ bool hasPlugin() => _slotFor() != null;
+
+ _PluginSlot? _slotFor() {
+ for (final slot in _plugins.values) {
+ if (slot.plugin is AuthPlugin) return slot;
+ }
+ return null;
+ }
+
+ /// Lifecycle events: [AuthEvent.signedIn], [AuthEvent.signedOut],
+ /// [AuthEvent.sessionRefreshed] and [AuthEvent.orgChanged].
+ Stream get events => _events.stream;
+
+ // ── Active organization ───────────────────────────
+
+ /// The organization the session is working in, null when it has none.
+ /// Restored from /v1/me on load, set by [switchOrg], cleared on sign-out.
+ String? get activeOrgId => _activeOrgId;
+
+ /// Switches the session into another organization. On an app that issues
+ /// JWTs the server hands back a new token carrying the organization; it
+ /// replaces the stored one. Fires [AuthEvent.orgChanged].
+ Future switchOrg(String orgId) async {
+ final current = _state;
+ if (current is! AuthAuthenticated) {
+ throw StateError('authsome: you have to be signed in to switch organization');
+ }
+ final res = await _client.switchOrg(
+ body: SwitchOrgRequest(orgId: orgId),
+ token: current.session.sessionToken,
+ );
+
+ final token = res.sessionToken;
+ if (token != null && token.isNotEmpty) {
+ final next = Session(
+ sessionToken: token,
+ refreshToken: current.session.refreshToken,
+ expiresAt: res.expiresAt ?? current.session.expiresAt,
+ );
+ await _persistSession(next);
+ _setState(AuthAuthenticated(user: current.user, session: next));
+ _scheduleRefresh(next);
+ }
+
+ final now = res.orgId ?? orgId;
+ final changed = _activeOrgId != now;
+ _activeOrgId = now;
+ if (changed) _emit(AuthEvent.orgChanged);
+ }
+
+ Future _loadUser(String token) async {
+ final me = await _client.getMe(token: token);
+ _reportedOrgId = me.activeOrgId;
+ return User.fromJson(me.toJson());
+ }
+
+ void _emit(AuthEvent event) {
+ if (!_events.isClosed) _events.add(event);
}
// ── Public API ────────────────────────────────────
@@ -155,7 +263,7 @@ class AuthManager {
// Token still valid — fetch user profile.
_setState(const AuthLoading());
- final user = await _client.getMeWithToken(session.sessionToken);
+ final user = await _loadUser(session.sessionToken);
_setState(AuthAuthenticated(user: user, session: session));
_scheduleRefresh(session);
} catch (_) {
@@ -538,6 +646,7 @@ class AuthManager {
_clearRefreshTimer();
_listeners.clear();
_configListeners.clear();
+ _events.close();
}
// ── Internals ─────────────────────────────────────
@@ -602,7 +711,7 @@ class AuthManager {
Future _refreshSession(String refreshToken) async {
try {
final newSession = await _client.refreshWithToken(refreshToken);
- final user = await _client.getMeWithToken(newSession.sessionToken);
+ final user = await _loadUser(newSession.sessionToken);
await _persistSession(newSession);
_setState(AuthAuthenticated(user: user, session: newSession));
_scheduleRefresh(newSession);
@@ -671,7 +780,9 @@ class AuthManager {
}
void _setState(AuthState newState) {
+ final prev = _state;
_state = newState;
+ final events = _transitionEvents(prev, newState);
for (final listener in _listeners) {
try {
listener(newState);
@@ -679,6 +790,39 @@ class AuthManager {
// Listener errors should not break the state machine.
}
}
+ for (final event in events) {
+ _emit(event);
+ }
+ }
+
+ /// Works out which lifecycle events a state change means, and keeps the
+ /// active organization in step: /v1/me reports it on every load, and
+ /// leaving the authenticated state forgets it.
+ List _transitionEvents(AuthState prev, AuthState next) {
+ final reported = _reportedOrgId;
+ _reportedOrgId = null;
+
+ if (prev is AuthAuthenticated && next is! AuthAuthenticated) {
+ _activeOrgId = null;
+ return const [AuthEvent.signedOut];
+ }
+ if (next is! AuthAuthenticated) return const [];
+ if (prev is! AuthAuthenticated) {
+ _activeOrgId = reported;
+ return const [AuthEvent.signedIn];
+ }
+
+ final events = [];
+ // A refresh rotates the refresh token; switching organization reissues
+ // only the access token, so it doesn't count.
+ if (prev.session.refreshToken != next.session.refreshToken) {
+ events.add(AuthEvent.sessionRefreshed);
+ }
+ if (reported != null && reported != _activeOrgId) {
+ _activeOrgId = reported;
+ events.add(AuthEvent.orgChanged);
+ }
+ return events;
}
void _handleError(Object err) {
@@ -709,3 +853,54 @@ class AuthManager {
_onError?.call(message, code);
}
}
+
+/// A registered plugin and, once used, what its init built (or threw).
+class _PluginSlot {
+ _PluginSlot(this.plugin);
+ final AuthPlugin
}>
+
+
+
+ canManageBilling(auth)} fallback={null}>
+
+
+```
+
+- `role` checks the app roles `/v1/me` returns for the signed-in user. Organization member roles (owner, admin, member) aren't in that list.
+- `permission` can't be checked in the browser, so it needs a `condition` that does the check, usually against something your backend told you. Without one, `Protect` shows the fallback and logs a warning once.
+- `condition` alone works too, for anything else.
+
+
+Before this release, `role` and `permission` didn't check anything: children rendered for every signed-in user. If you relied on that, your protected UI is now hidden until you pass a role the user actually holds, or a `condition` for permissions.
+
+
## Integration with React Router
```tsx
diff --git a/flutter/packages/authsome_core/lib/src/auth_manager.dart b/flutter/packages/authsome_core/lib/src/auth_manager.dart
index 393b7cb7..83400753 100644
--- a/flutter/packages/authsome_core/lib/src/auth_manager.dart
+++ b/flutter/packages/authsome_core/lib/src/auth_manager.dart
@@ -146,10 +146,19 @@ class AuthManager {
bool hasPlugin() => _slotFor() != null;
_PluginSlot? _slotFor() {
- for (final slot in _plugins.values) {
- if (slot.plugin is AuthPlugin) return slot;
+ if (Api == Object) {
+ throw ArgumentError(
+ 'authsome: plugin() needs the API type, e.g. plugin()');
}
- return null;
+ final matches = [
+ for (final slot in _plugins.values)
+ if (slot.plugin is AuthPlugin) slot,
+ ];
+ if (matches.length > 1) {
+ throw StateError('authsome: more than one registered plugin provides $Api '
+ '(${matches.map((s) => s.plugin.name).join(', ')})');
+ }
+ return matches.isEmpty ? null : matches.single;
}
/// Lifecycle events: [AuthEvent.signedIn], [AuthEvent.signedOut],
@@ -166,33 +175,74 @@ class AuthManager {
/// JWTs the server hands back a new token carrying the organization; it
/// replaces the stored one. Fires [AuthEvent.orgChanged].
Future switchOrg(String orgId) async {
- final current = _state;
- if (current is! AuthAuthenticated) {
+ if (_state is! AuthAuthenticated) {
+ throw StateError('authsome: you have to be signed in to switch organization');
+ }
+ // Runs in turn with refreshes, so a refresh rotating the refresh token
+ // meanwhile isn't overwritten with the spent one.
+ return _exclusive(() => _applyOrgSwitch(orgId));
+ }
+
+ Future _applyOrgSwitch(String orgId) async {
+ final before = _state;
+ if (before is! AuthAuthenticated) {
throw StateError('authsome: you have to be signed in to switch organization');
}
final res = await _client.switchOrg(
body: SwitchOrgRequest(orgId: orgId),
- token: current.session.sessionToken,
+ token: before.session.sessionToken,
);
+ // The session may have ended, or become someone else's, while the
+ // request was out. Don't bring it back.
+ final now = _state;
+ if (now is! AuthAuthenticated || _userId(now) != _userId(before)) {
+ throw StateError('authsome: the session changed while switching organization');
+ }
+
final token = res.sessionToken;
if (token != null && token.isNotEmpty) {
+ final raw = await _storage.getItem(_sessionKey);
+ final latest = raw == null
+ ? now.session
+ : Session.fromJson(jsonDecode(raw) as Map);
final next = Session(
sessionToken: token,
- refreshToken: current.session.refreshToken,
- expiresAt: res.expiresAt ?? current.session.expiresAt,
+ refreshToken: latest.refreshToken,
+ expiresAt: res.expiresAt ?? latest.expiresAt,
);
await _persistSession(next);
- _setState(AuthAuthenticated(user: current.user, session: next));
+ _setState(AuthAuthenticated(user: now.user, session: next));
_scheduleRefresh(next);
}
- final now = res.orgId ?? orgId;
- final changed = _activeOrgId != now;
- _activeOrgId = now;
+ // An empty org id is the server saying the session has none now.
+ final reported = res.orgId;
+ final org = (reported != null && reported.isNotEmpty) ? reported : null;
+ final changed = _activeOrgId != org;
+ _activeOrgId = org;
if (changed) _emit(AuthEvent.orgChanged);
}
+ /// Runs [op] after every operation queued before it has finished, so a
+ /// refresh and an organization switch never interleave.
+ Future _exclusive(Future Function() op) {
+ final result = _serial.then((_) => op());
+ _serial = result.then((_) {}, onError: (Object _) {});
+ return result;
+ }
+
+ Future _serial = Future.value();
+
+ /// The signed-in user's id, null when unknown (an offline restore).
+ static String? _userId(AuthState state) {
+ if (state is! AuthAuthenticated) return null;
+ final user = state.user;
+ if (user is User) return user.id;
+ if (user is Map) return user['id'] as String?;
+ return null;
+ }
+
Future _loadUser(String token) async {
final me = await _client.getMe(token: token);
_reportedOrgId = me.activeOrgId;
@@ -708,7 +758,10 @@ class AuthManager {
/// may still be valid and the server could be temporarily unreachable.
/// Schedules a 30-second retry instead. Only [signOut] clears storage.
/// Mirrors React `auth.ts` refreshSession (lines 476–492).
- Future _refreshSession(String refreshToken) async {
+ Future _refreshSession(String refreshToken) =>
+ _exclusive(() => _doRefreshSession(refreshToken));
+
+ Future _doRefreshSession(String refreshToken) async {
try {
final newSession = await _client.refreshWithToken(refreshToken);
final user = await _loadUser(newSession.sessionToken);
@@ -812,6 +865,15 @@ class AuthManager {
return const [AuthEvent.signedIn];
}
+ // Signing in as someone else without signing out first is a sign-out
+ // and a sign-in, so nothing of the previous user's carries over.
+ final prevUser = _userId(prev);
+ final nextUser = _userId(next);
+ if (prevUser != null && nextUser != null && prevUser != nextUser) {
+ _activeOrgId = reported;
+ return const [AuthEvent.signedOut, AuthEvent.signedIn];
+ }
+
final events = [];
// A refresh rotates the refresh token; switching organization reissues
// only the access token, so it doesn't count.
diff --git a/flutter/packages/authsome_core/test/plugins_test.dart b/flutter/packages/authsome_core/test/plugins_test.dart
index d4880748..2ba59687 100644
--- a/flutter/packages/authsome_core/test/plugins_test.dart
+++ b/flutter/packages/authsome_core/test/plugins_test.dart
@@ -1,3 +1,4 @@
+import 'dart:async';
import 'dart:convert';
import 'package:authsome_core/authsome_core.dart';
@@ -56,7 +57,7 @@ final _me = {
'updated_at': '2026-01-01T00:00:00Z',
};
-typedef _Routes = Map;
+typedef _Routes = Map Function(http.Request)>;
({AuthManager manager, _Routes routes, List calls, MemoryTokenStorage storage}) _setup({
bool signedIn = false,
@@ -65,7 +66,7 @@ typedef _Routes = Map;
ClientConfig? clientConfig,
String? publishableKey,
}) {
- final routes = {
+ final routes = Function(http.Request)>{
'GET /v1/me': (_) => http.Response(jsonEncode({..._me, ...me}), 200),
'POST /v1/signout': (_) => http.Response('{"status":"ok"}', 200),
};
@@ -220,4 +221,90 @@ void main() {
expect(s.manager.activeOrgId, isNull);
});
});
+
+ group('review fixes', () {
+ http.Response switched(String org, {String? token}) => http.Response(jsonEncode({
+ 'session_id': 'ses_1',
+ 'org_id': org,
+ if (token != null) 'session_token': token,
+ if (token != null) 'expires_at': _session['expires_at'],
+ }), 200);
+
+ test('keeps a token a refresh rotated while the switch was in flight', () async {
+ final s = _setup(signedIn: true);
+ await s.manager.initialize();
+ final gate = Completer();
+ s.routes['POST /v1/me/switch-org'] = (_) => gate.future;
+ s.routes['POST /v1/refresh'] = (_) => http.Response(jsonEncode({
+ ..._session,
+ 'session_token': 'token-2',
+ 'refresh_token': 'refresh-2',
+ }), 200);
+
+ final switching = s.manager.switchOrg('org_2');
+ final refreshing = s.manager.refreshNow();
+ await Future.delayed(const Duration(milliseconds: 20));
+ gate.complete(switched('org_2', token: 'token-org2'));
+ await Future.wait([switching, refreshing]);
+
+ final stored = jsonDecode((await s.storage.getItem('authsome:session'))!) as Map;
+ expect(stored['refresh_token'], 'refresh-2');
+ });
+
+ test('does not bring back a session that signed out during the switch', () async {
+ final s = _setup(signedIn: true);
+ await s.manager.initialize();
+ final gate = Completer();
+ s.routes['POST /v1/me/switch-org'] = (_) => gate.future;
+
+ final switching = s.manager.switchOrg('org_2');
+ await s.manager.signOut();
+ gate.complete(switched('org_2', token: 'token-org2'));
+ await switching.catchError((_) {});
+
+ expect(s.manager.state, isA());
+ expect(await s.storage.getItem('authsome:session'), isNull);
+ expect(s.manager.activeOrgId, isNull);
+ });
+
+ test('treats signing in as someone else as a sign-out and a sign-in', () async {
+ final s = _setup(signedIn: true, me: {'active_org_id': 'org_A'});
+ await s.manager.initialize();
+ final seen = [];
+ s.manager.events.listen(seen.add);
+ s.routes['POST /v1/signin'] = (_) => http.Response(jsonEncode({
+ 'user': {..._me, 'id': 'usr_2'},
+ 'session_token': 't2',
+ 'refresh_token': 'r2',
+ 'expires_at': _session['expires_at'],
+ }), 200);
+
+ await s.manager.signIn('grace@test', 'pw');
+ expect(seen, [AuthEvent.signedOut, AuthEvent.signedIn]);
+ expect(s.manager.activeOrgId, isNull);
+ });
+
+ test('refuses an untyped or ambiguous plugin lookup', () {
+ final untyped = _setup(plugins: [_ProbePlugin()]);
+ expect(() => untyped.manager.plugin(), throwsA(isA()));
+
+ final twice = _setup(plugins: [_OtherPlugin(), _OtherNamePlugin()]);
+ expect(() => twice.manager.plugin(), throwsA(isA()));
+ });
+
+ test('clears the org when switching to none', () async {
+ final s = _setup(signedIn: true, me: {'active_org_id': 'org_A'});
+ await s.manager.initialize();
+ s.routes['POST /v1/me/switch-org'] = (_) => http.Response('{"session_id":"ses_1"}', 200);
+ await s.manager.switchOrg('');
+ expect(s.manager.activeOrgId, isNull);
+ });
+ });
+}
+
+class _OtherNamePlugin extends AuthPlugin {
+ @override
+ String get name => 'other-two';
+ @override
+ String init(PluginContext ctx) => 'two';
}
diff --git a/ui/packages/components/src/components/org-switcher.test.tsx b/ui/packages/components/src/components/org-switcher.test.tsx
index c93ae6da..a6905e2e 100644
--- a/ui/packages/components/src/components/org-switcher.test.tsx
+++ b/ui/packages/components/src/components/org-switcher.test.tsx
@@ -37,6 +37,26 @@ describe("OrgSwitcher", () => {
await waitFor(() => expect(screen.getByRole("button", { name: /Globex/ })).toBeTruthy());
});
+ it("tells the user when a switch fails", async () => {
+ const onError = vi.fn();
+ const { fetchFn } = routedFetch({
+ "GET /v1/me": () => ({ ...makeUser(), active_org_id: "org_1" }),
+ "GET /v1/orgs": () => ({ organizations: orgs }),
+ "POST /v1/me/switch-org": () => new Response(JSON.stringify({ error: "conflict" }), { status: 409 }),
+ });
+
+ render(withProvider(, { fetch: fetchFn }));
+ await waitFor(() => expect(screen.getByRole("button", { name: /Acme/ })).toBeTruthy());
+
+ open("Acme");
+ const item = await screen.findByRole("menuitem", { name: /Globex/ });
+ await act(async () => { fireEvent.click(item); });
+
+ await waitFor(() => expect(screen.getByRole("alert").textContent).toMatch(/couldn't switch/i));
+ expect(onError).toHaveBeenCalledTimes(1);
+ expect(screen.getByRole("button", { name: /Acme/ })).toBeTruthy();
+ });
+
it("leaves switching to the app when it passes onOrgChange", async () => {
const onOrgChange = vi.fn();
const { fetchFn, calls } = routedFetch({
diff --git a/ui/packages/components/src/components/org-switcher.tsx b/ui/packages/components/src/components/org-switcher.tsx
index a23a45dc..5ec12006 100644
--- a/ui/packages/components/src/components/org-switcher.tsx
+++ b/ui/packages/components/src/components/org-switcher.tsx
@@ -37,6 +37,11 @@ export interface OrgSwitcherProps {
onCreateOrg?: () => void;
/** The currently selected organization ID. Leave it out to show the session's. */
activeOrgId?: string;
+ /**
+ * Called when switching the session's organization fails. The switcher
+ * also shows a short message under itself.
+ */
+ onError?: (error: unknown) => void;
/** Additional CSS class names. */
className?: string;
}
@@ -49,19 +54,25 @@ export function OrgSwitcher({
onOrgChange,
onCreateOrg,
activeOrgId: activeOrgIdProp,
+ onError,
className,
}: OrgSwitcherProps) {
const { client, session } = useAuth();
- const { orgId: sessionOrgId, switchOrg } = useActiveOrg();
+ const { orgId: sessionOrgId, switchOrg, isSwitching } = useActiveOrg();
const activeOrgId = activeOrgIdProp ?? sessionOrgId ?? undefined;
+ const [switchError, setSwitchError] = useState(null);
const select = (orgId: string) => {
if (onOrgChange) {
onOrgChange(orgId);
return;
}
- // A failed switch leaves the session where it was, which is what the
- // switcher already shows, so there's nothing to undo.
- switchOrg(orgId).catch(() => {});
+ setSwitchError(null);
+ // A failed switch leaves the session where it was, which the switcher
+ // already shows; say so rather than leave the click unanswered.
+ switchOrg(orgId).catch((err: unknown) => {
+ setSwitchError("Couldn't switch organization. Try again.");
+ onError?.(err);
+ });
};
const { organizations: orgsRaw, isLoading, reload } = useOrganizations();
const organizations = orgsRaw ?? [];
@@ -124,6 +135,7 @@ export function OrgSwitcher({