@marianmeres/ownsuite 2.1.0 → 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -2
- package/API.md +15 -5
- package/README.md +5 -2
- package/dist/domains/auth.d.ts +7 -5
- package/dist/domains/auth.js +23 -10
- package/dist/domains/session.d.ts +26 -7
- package/dist/domains/session.js +123 -26
- package/dist/types/auth.d.ts +20 -0
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -160,9 +160,9 @@ export type { MockAuthStore } from "./adapters/mod.ts";
|
|
|
160
160
|
|
|
161
161
|
When `adapters.auth` is supplied, `createOwnsuite` instantiates three extra managers and attaches them as readonly suite properties:
|
|
162
162
|
|
|
163
|
-
- **`suite.session: SessionManager`** — reactive `{ status, subject, jwt, expiresAt }` persisted via pluggable `SessionStorage` (`"local"` / `"session"` / `"memory"` / custom object).
|
|
163
|
+
- **`suite.session: SessionManager`** — reactive `{ status, subject, jwt, expiresAt }` persisted via pluggable `SessionStorage` (`"local"` / `"session"` / `"memory"` / custom object). With a built-in string backend, hydrates by probing in order `local → session → memory` and adopts the first non-expired payload as the active backend for the instance's lifetime (losing backends are wiped). `setAuthenticated({ storage })` pins the session to a specific built-in backend per login ("Remember me"); subsequent `patchSubject` / `setUnverified` writes land on the same backend. `clear()` wipes every built-in backend so stale blobs from a previous toggle don't leak back in. Exposes `subscribe` (Svelte-compatible), `get()`, and state-mutation methods (`setAuthenticated`, `setUnverified`, `clear`, `patchSubject`). Writes are driven by `AuthManager`, not consumers directly.
|
|
164
164
|
|
|
165
|
-
- **`suite.auth: AuthManager`** — verbs only, no state. `register` / `login` / `logout` / `resendVerification` / `requestPasswordReset` / `changePassword` / `deleteAccount` / `initiateOAuth` (`mode: "popup" | "redirect"`) / `handleOAuthCallback` (redirect mode). Each call pipes the result into the session and fires an `onIdentityChanged` hook that resets + re-initializes every owner-scoped domain with the fresh context.
|
|
165
|
+
- **`suite.auth: AuthManager`** — verbs only, no state. `register(input, options?)` / `login(input, options?)` / `logout` / `resendVerification` / `requestPasswordReset` / `changePassword` / `deleteAccount` / `initiateOAuth(provider, opts)` (`mode: "popup" | "redirect"`) / `handleOAuthCallback(options?)` (redirect mode). The `options.remember` boolean (`true` → localStorage, `false` → sessionStorage, `undefined` → session manager default; same field on `OAuthInitOptions`) pins the resulting session's storage backend. Each call pipes the result into the session and fires an `onIdentityChanged` hook that resets + re-initializes every owner-scoped domain with the fresh context.
|
|
166
166
|
|
|
167
167
|
- **`suite.profile: ProfileManager`** — singleton (one-row) `/me` CRUD. `fetch` / `update` / `listOAuth` / `unlinkOAuth`. Every successful fetch/update patches the session subject in place so consumers reading `suite.session` see email / roles / verification / connections without a second fetch. Update emits `profile:updated`.
|
|
168
168
|
|
package/API.md
CHANGED
|
@@ -593,10 +593,12 @@ Current JWT, or `null` when anonymous.
|
|
|
593
593
|
|
|
594
594
|
Shorthand reads.
|
|
595
595
|
|
|
596
|
-
#### `session.setAuthenticated({ jwt, subject, expiresAt? })`
|
|
596
|
+
#### `session.setAuthenticated({ jwt, subject, expiresAt?, storage? })`
|
|
597
597
|
|
|
598
598
|
Enter the `authenticated` state. Normally called by `AuthManager`, not consumers.
|
|
599
599
|
|
|
600
|
+
- `storage` (`SessionStorageType`, optional) — per-login storage pin. When `"local"` / `"session"` / `"memory"`, switches the active built-in backend for this session (and for subsequent `patchSubject` / `setUnverified` writes). The previously-active built-in backend's blob is wiped as part of the switch so "Remember me" toggles don't leave stale data. Silently ignored when the manager was constructed with a custom `SessionStorage` object, or when the override is itself an object.
|
|
601
|
+
|
|
600
602
|
#### `session.setUnverified(email)` / `session.clear()` / `session.patchSubject(patch)`
|
|
601
603
|
|
|
602
604
|
Mutation helpers — typically driven by `AuthManager` / `ProfileManager`.
|
|
@@ -613,15 +615,15 @@ Verbs only. Attached as `suite.auth`. No state of its own — results flow into
|
|
|
613
615
|
|
|
614
616
|
| Method | Purpose |
|
|
615
617
|
|---|---|
|
|
616
|
-
| `register({ email, password, password_confirm, roles?, extras? })` | Create account. Returns an `AuthTokenResult`. When the server's verification gate is on, the result carries `requiresVerification: true` and the session flips to `"unverified"` — no JWT yet. |
|
|
617
|
-
| `login({ email, password })` | Exchange credentials for a JWT. Session flips to `"authenticated"` on success, `"unverified"` if the server reports the gate. |
|
|
618
|
+
| `register({ email, password, password_confirm, roles?, extras? }, options?)` | Create account. Returns an `AuthTokenResult`. When the server's verification gate is on, the result carries `requiresVerification: true` and the session flips to `"unverified"` — no JWT yet. `options.remember` pins the resulting session to `localStorage` (`true`) or `sessionStorage` (`false`); omit to use the `SessionManager` default. |
|
|
619
|
+
| `login({ email, password }, options?)` | Exchange credentials for a JWT. Session flips to `"authenticated"` on success, `"unverified"` if the server reports the gate. `options.remember` selects per-login storage (see `register`). |
|
|
618
620
|
| `logout()` | Best-effort server revoke + local clear. Idempotent. |
|
|
619
621
|
| `resendVerification({ email, lang? })` | Trigger a fresh verification email. Anti-enumeration: always resolves. |
|
|
620
622
|
| `requestPasswordReset({ email, lang? })` | Trigger a password-reset email. Anti-enumeration. |
|
|
621
623
|
| `changePassword({ current_password?, new_password, confirm_password, token? })` | Authenticated self-change (with `current_password`) or token-based reset. |
|
|
622
624
|
| `deleteAccount({ password?, confirm? })` | Irreversible server delete + local session clear + identity-changed hook. |
|
|
623
|
-
| `initiateOAuth(provider, opts)` | Start an OAuth flow. `mode: "popup"` (default) resolves with the auth result from the popup's `postMessage`; `mode: "redirect"` navigates the top window. |
|
|
624
|
-
| `handleOAuthCallback()` | For `mode: "redirect"` apps, call from your callback route to extract the result from the URL (delegated to `adapter.handleOAuthCallback`). |
|
|
625
|
+
| `initiateOAuth(provider, opts)` | Start an OAuth flow. `mode: "popup"` (default) resolves with the auth result from the popup's `postMessage`; `mode: "redirect"` navigates the top window. `opts.remember` pins the resulting session's storage backend (only meaningful for `action: "login"`). |
|
|
626
|
+
| `handleOAuthCallback(options?)` | For `mode: "redirect"` apps, call from your callback route to extract the result from the URL (delegated to `adapter.handleOAuthCallback`). `options.remember` pins the resulting session's storage — pass the same value the user picked before the redirect. |
|
|
625
627
|
|
|
626
628
|
Successful identity changes (register-with-autologin, login, OAuth login, logout, deleteAccount) fire the orchestrator's `onIdentityChanged` hook, which resets every owner-scoped domain and re-initializes them with the new context.
|
|
627
629
|
|
|
@@ -735,6 +737,14 @@ interface OAuthInitOptions {
|
|
|
735
737
|
redirect?: string;
|
|
736
738
|
lang?: string;
|
|
737
739
|
mode?: "popup" | "redirect"; // default "popup"
|
|
740
|
+
remember?: boolean; // same semantics as AuthActionOptions.remember
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
interface AuthActionOptions {
|
|
744
|
+
/** true → localStorage; false → sessionStorage; undefined → default.
|
|
745
|
+
* Ignored when SessionManager was constructed with a custom
|
|
746
|
+
* SessionStorage object. */
|
|
747
|
+
remember?: boolean;
|
|
738
748
|
}
|
|
739
749
|
|
|
740
750
|
interface OAuthConnection {
|
package/README.md
CHANGED
|
@@ -67,7 +67,10 @@ await suite.auth!.register({
|
|
|
67
67
|
// suite.session!.get().status === "unverified"
|
|
68
68
|
|
|
69
69
|
// After the user clicks the email link and the server flips isVerified:
|
|
70
|
-
await suite.auth!.login(
|
|
70
|
+
await suite.auth!.login(
|
|
71
|
+
{ email: "alice@example.com", password: "mysecretpassword" },
|
|
72
|
+
{ remember: true }, // true → localStorage; false → sessionStorage (per-login override)
|
|
73
|
+
);
|
|
71
74
|
// suite.session!.get().status === "authenticated"
|
|
72
75
|
// Every registered owner-scoped domain is re-initialized with the new JWT.
|
|
73
76
|
|
|
@@ -86,7 +89,7 @@ await suite.profile!.update({
|
|
|
86
89
|
await suite.auth!.logout();
|
|
87
90
|
```
|
|
88
91
|
|
|
89
|
-
Session state is persisted through a pluggable `SessionStorage` backend (`"local"` / `"session"` / `"memory"` / custom object with `get`/`set`/`del`). Expired stored sessions are discarded on construction so a reload after the JWT lapses starts anonymous.
|
|
92
|
+
Session state is persisted through a pluggable `SessionStorage` backend (`"local"` / `"session"` / `"memory"` / custom object with `get`/`set`/`del`). Expired stored sessions are discarded on construction so a reload after the JWT lapses starts anonymous. With a built-in string backend, hydration probes `local → session → memory` and adopts whichever holds a non-expired payload — the per-login `remember` flag on `auth.login` / `auth.register` / `auth.initiateOAuth` (`true` → `localStorage`, `false` → `sessionStorage`) switches the active backend for that session, and `session.clear()` wipes all built-in backends so toggles can't leak stale data.
|
|
90
93
|
|
|
91
94
|
Tests can use the in-memory mock adapters (`createMockAuthAdapter`, `createMockProfileAdapter`, `createMockAuthStore`, `verifyMockAccount`) and the injectable popup host for deterministic OAuth dances.
|
|
92
95
|
|
package/dist/domains/auth.d.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* management.
|
|
13
13
|
*/
|
|
14
14
|
import { type PubSub } from "@marianmeres/pubsub";
|
|
15
|
-
import type { AuthAdapter, AuthTokenResult, OAuthInitOptions, OAuthProvider, OwnsuiteContext, ProfileAdapter } from "../types/mod.js";
|
|
15
|
+
import type { AuthActionOptions, AuthAdapter, AuthTokenResult, OAuthInitOptions, OAuthProvider, OwnsuiteContext, ProfileAdapter } from "../types/mod.js";
|
|
16
16
|
import type { SessionManager } from "./session.js";
|
|
17
17
|
import type { ProfileManager } from "./profile.js";
|
|
18
18
|
export interface AuthManagerOptions {
|
|
@@ -46,11 +46,11 @@ export declare class AuthManager {
|
|
|
46
46
|
password_confirm: string;
|
|
47
47
|
roles?: string[];
|
|
48
48
|
extras?: Record<string, unknown>;
|
|
49
|
-
}): Promise<AuthTokenResult>;
|
|
49
|
+
}, options?: AuthActionOptions): Promise<AuthTokenResult>;
|
|
50
50
|
login(input: {
|
|
51
51
|
email: string;
|
|
52
52
|
password: string;
|
|
53
|
-
}): Promise<AuthTokenResult>;
|
|
53
|
+
}, options?: AuthActionOptions): Promise<AuthTokenResult>;
|
|
54
54
|
logout(): Promise<void>;
|
|
55
55
|
resendVerification(input: {
|
|
56
56
|
email: string;
|
|
@@ -78,6 +78,8 @@ export declare class AuthManager {
|
|
|
78
78
|
*/
|
|
79
79
|
initiateOAuth(provider: OAuthProvider, opts: OAuthInitOptions): Promise<AuthTokenResult | void>;
|
|
80
80
|
/** For the redirect-mode callback page: delegate to the adapter if
|
|
81
|
-
* available to extract the result from the current URL/page state.
|
|
82
|
-
|
|
81
|
+
* available to extract the result from the current URL/page state.
|
|
82
|
+
* `options.remember` pins the resulting session to local/session
|
|
83
|
+
* storage — pass the same value the user picked before the redirect. */
|
|
84
|
+
handleOAuthCallback(options?: AuthActionOptions): Promise<AuthTokenResult | void>;
|
|
83
85
|
}
|
package/dist/domains/auth.js
CHANGED
|
@@ -46,8 +46,13 @@ export class AuthManager {
|
|
|
46
46
|
* present, we pull the profile to build a complete subject. When the
|
|
47
47
|
* server returned requiresVerification, we surface the "unverified"
|
|
48
48
|
* status without any profile fetch. Triggers identity-change hook on
|
|
49
|
-
* actual login.
|
|
50
|
-
|
|
49
|
+
* actual login.
|
|
50
|
+
*
|
|
51
|
+
* `remember` translates to a per-login storage pin on the session:
|
|
52
|
+
* `true` → `"local"`, `false` → `"session"`, `undefined` → session
|
|
53
|
+
* manager's configured default. Silently no-op in the verification-gate
|
|
54
|
+
* branch (no JWT yet → nothing to persist). */
|
|
55
|
+
async #applyAuthResult(result, remember) {
|
|
51
56
|
if (result.requiresVerification || !result.jwt) {
|
|
52
57
|
this.#session.setUnverified(result.email);
|
|
53
58
|
this.#pubsub.publish("auth:verification:required", {
|
|
@@ -66,10 +71,16 @@ export class AuthManager {
|
|
|
66
71
|
isVerified: result.isVerified ?? false,
|
|
67
72
|
hasPassword: true, // corrected by profile fetch below
|
|
68
73
|
};
|
|
74
|
+
const storage = remember === true
|
|
75
|
+
? "local"
|
|
76
|
+
: remember === false
|
|
77
|
+
? "session"
|
|
78
|
+
: undefined;
|
|
69
79
|
this.#session.setAuthenticated({
|
|
70
80
|
jwt: result.jwt,
|
|
71
81
|
subject: provisional,
|
|
72
82
|
expiresAt: result.validUntil ?? null,
|
|
83
|
+
...(storage ? { storage } : {}),
|
|
73
84
|
});
|
|
74
85
|
// Best-effort hydrate the subject from /me. If this fails we keep the
|
|
75
86
|
// provisional subject; consumers can retry via profile.fetch().
|
|
@@ -90,7 +101,7 @@ export class AuthManager {
|
|
|
90
101
|
return result;
|
|
91
102
|
}
|
|
92
103
|
// ─────────────────────── verbs ──────────────────────────────────────────
|
|
93
|
-
async register(input) {
|
|
104
|
+
async register(input, options) {
|
|
94
105
|
const result = await this.#adapter.register(input, this.#ctx());
|
|
95
106
|
this.#pubsub.publish("auth:register", {
|
|
96
107
|
type: "auth:register",
|
|
@@ -98,16 +109,16 @@ export class AuthManager {
|
|
|
98
109
|
email: input.email,
|
|
99
110
|
requiresVerification: Boolean(result.requiresVerification),
|
|
100
111
|
});
|
|
101
|
-
return await this.#applyAuthResult(result);
|
|
112
|
+
return await this.#applyAuthResult(result, options?.remember);
|
|
102
113
|
}
|
|
103
|
-
async login(input) {
|
|
114
|
+
async login(input, options) {
|
|
104
115
|
const result = await this.#adapter.login(input, this.#ctx());
|
|
105
116
|
this.#pubsub.publish("auth:login", {
|
|
106
117
|
type: "auth:login",
|
|
107
118
|
timestamp: Date.now(),
|
|
108
119
|
email: input.email,
|
|
109
120
|
});
|
|
110
|
-
return await this.#applyAuthResult(result);
|
|
121
|
+
return await this.#applyAuthResult(result, options?.remember);
|
|
111
122
|
}
|
|
112
123
|
async logout() {
|
|
113
124
|
const subjectId = this.#session.get().subject?.id;
|
|
@@ -197,15 +208,17 @@ export class AuthManager {
|
|
|
197
208
|
roles: message.roles ?? [],
|
|
198
209
|
isVerified: true,
|
|
199
210
|
};
|
|
200
|
-
return await this.#applyAuthResult(result);
|
|
211
|
+
return await this.#applyAuthResult(result, opts.remember);
|
|
201
212
|
}
|
|
202
213
|
/** For the redirect-mode callback page: delegate to the adapter if
|
|
203
|
-
* available to extract the result from the current URL/page state.
|
|
204
|
-
|
|
214
|
+
* available to extract the result from the current URL/page state.
|
|
215
|
+
* `options.remember` pins the resulting session to local/session
|
|
216
|
+
* storage — pass the same value the user picked before the redirect. */
|
|
217
|
+
async handleOAuthCallback(options) {
|
|
205
218
|
if (!this.#adapter.handleOAuthCallback) {
|
|
206
219
|
throw new Error("AuthManager: handleOAuthCallback is not implemented by the adapter");
|
|
207
220
|
}
|
|
208
221
|
const result = await this.#adapter.handleOAuthCallback(this.#ctx());
|
|
209
|
-
return await this.#applyAuthResult(result);
|
|
222
|
+
return await this.#applyAuthResult(result, options?.remember);
|
|
210
223
|
}
|
|
211
224
|
}
|
|
@@ -34,9 +34,14 @@ export interface SessionManagerOptions {
|
|
|
34
34
|
/**
|
|
35
35
|
* Session manager — pure reactive state + persistence, no HTTP.
|
|
36
36
|
*
|
|
37
|
-
* Writes are driven by the AuthManager. On construction it hydrates
|
|
38
|
-
*
|
|
39
|
-
*
|
|
37
|
+
* Writes are driven by the AuthManager. On construction it hydrates by
|
|
38
|
+
* probing the built-in backends in order `local → session → memory` and
|
|
39
|
+
* adopting whichever holds a non-expired payload as the active backend for
|
|
40
|
+
* the rest of the instance's lifetime (until `clear()`). Stale blobs on the
|
|
41
|
+
* other built-in backends are wiped on adoption.
|
|
42
|
+
*
|
|
43
|
+
* When constructed with a custom `SessionStorage` object, there is a single
|
|
44
|
+
* backend and per-login `remember` choices are silently ignored.
|
|
40
45
|
*/
|
|
41
46
|
export declare class SessionManager {
|
|
42
47
|
#private;
|
|
@@ -51,23 +56,37 @@ export declare class SessionManager {
|
|
|
51
56
|
/** JWT for adapter `Authorization` headers, or null when anonymous. */
|
|
52
57
|
getJwt(): string | null;
|
|
53
58
|
/** Transition to authenticated. Called after login/register/OAuth succeed
|
|
54
|
-
* and the subject has been loaded.
|
|
59
|
+
* and the subject has been loaded.
|
|
60
|
+
*
|
|
61
|
+
* When `opts.storage` is one of `"local"` / `"session"` / `"memory"`,
|
|
62
|
+
* pins this session to that built-in backend; subsequent
|
|
63
|
+
* `patchSubject` / `setUnverified` writes land on the same backend.
|
|
64
|
+
* The previously-active backend's blob is wiped as part of the switch
|
|
65
|
+
* so "Remember me" toggles don't leave stale data behind.
|
|
66
|
+
*
|
|
67
|
+
* Ignored when the manager was constructed with a custom `SessionStorage`
|
|
68
|
+
* object (single backend) or when `opts.storage` is itself an object
|
|
69
|
+
* (no multi-backend custom storage by design). */
|
|
55
70
|
setAuthenticated(opts: {
|
|
56
71
|
jwt: string;
|
|
57
72
|
subject: SessionSubject;
|
|
58
73
|
expiresAt?: number | null;
|
|
74
|
+
storage?: SessionStorageType;
|
|
59
75
|
}): void;
|
|
60
76
|
/** Server confirmed credentials but refuses login until email is
|
|
61
77
|
* verified. Expose the email so the UI can prompt "check your inbox"
|
|
62
78
|
* without a second server call. No JWT in this state. */
|
|
63
79
|
setUnverified(email: string): void;
|
|
64
|
-
/** Drop the session.
|
|
65
|
-
*
|
|
80
|
+
/** Drop the session. Every built-in backend (local + session + memory)
|
|
81
|
+
* is wiped — not just the active one — so stale blobs from a previous
|
|
82
|
+
* "Remember me" toggle can't leak back in on the next construction.
|
|
83
|
+
* Resets the active backend to the manager's configured default.
|
|
84
|
+
* Downstream domains should be reset by the suite orchestrator. */
|
|
66
85
|
clear(): void;
|
|
67
86
|
/** Patch the subject in place without touching the JWT. Used when the
|
|
68
87
|
* profile manager changes email / linked providers / etc. */
|
|
69
88
|
patchSubject(patch: Partial<SessionSubject>): void;
|
|
70
|
-
/** Test / reset hook — clears
|
|
89
|
+
/** Test / reset hook — clears every backend AND in-memory state without
|
|
71
90
|
* emitting. Used by teardown. */
|
|
72
91
|
destroy(): void;
|
|
73
92
|
}
|
package/dist/domains/session.js
CHANGED
|
@@ -16,6 +16,11 @@
|
|
|
16
16
|
import { createStore } from "@marianmeres/store";
|
|
17
17
|
import { createPubSub } from "@marianmeres/pubsub";
|
|
18
18
|
const DEFAULT_STORAGE_KEY = "ownsuite:session";
|
|
19
|
+
const BUILT_IN_ORDER = [
|
|
20
|
+
"local",
|
|
21
|
+
"session",
|
|
22
|
+
"memory",
|
|
23
|
+
];
|
|
19
24
|
const EMPTY = {
|
|
20
25
|
status: "anonymous",
|
|
21
26
|
subject: null,
|
|
@@ -82,56 +87,114 @@ export function resolveSessionStorage(type = "local") {
|
|
|
82
87
|
/**
|
|
83
88
|
* Session manager — pure reactive state + persistence, no HTTP.
|
|
84
89
|
*
|
|
85
|
-
* Writes are driven by the AuthManager. On construction it hydrates
|
|
86
|
-
*
|
|
87
|
-
*
|
|
90
|
+
* Writes are driven by the AuthManager. On construction it hydrates by
|
|
91
|
+
* probing the built-in backends in order `local → session → memory` and
|
|
92
|
+
* adopting whichever holds a non-expired payload as the active backend for
|
|
93
|
+
* the rest of the instance's lifetime (until `clear()`). Stale blobs on the
|
|
94
|
+
* other built-in backends are wiped on adoption.
|
|
95
|
+
*
|
|
96
|
+
* When constructed with a custom `SessionStorage` object, there is a single
|
|
97
|
+
* backend and per-login `remember` choices are silently ignored.
|
|
88
98
|
*/
|
|
89
99
|
export class SessionManager {
|
|
90
100
|
#store;
|
|
91
101
|
#pubsub;
|
|
92
|
-
#storage;
|
|
93
102
|
#storageKey;
|
|
103
|
+
/** Set only when the consumer passed a `SessionStorage` object at
|
|
104
|
+
* construction — then there is one backend and no toggling. */
|
|
105
|
+
#customStorage;
|
|
106
|
+
/** Resolved eagerly in the string-storage case so `clear()` can wipe
|
|
107
|
+
* every backend and per-login overrides can switch between them. */
|
|
108
|
+
#builtIn;
|
|
109
|
+
#defaultStorageType;
|
|
110
|
+
#activeStorage;
|
|
111
|
+
#activeStorageType;
|
|
94
112
|
constructor(options = {}) {
|
|
95
113
|
this.#pubsub = options.pubsub ?? createPubSub();
|
|
96
|
-
this.#storage = resolveSessionStorage(options.storage ?? "local");
|
|
97
114
|
this.#storageKey = options.storageKey ?? DEFAULT_STORAGE_KEY;
|
|
98
115
|
this.#store = createStore({ ...EMPTY });
|
|
116
|
+
const configured = options.storage ?? "local";
|
|
117
|
+
if (typeof configured === "string") {
|
|
118
|
+
this.#customStorage = null;
|
|
119
|
+
this.#builtIn = {
|
|
120
|
+
local: resolveSessionStorage("local"),
|
|
121
|
+
session: resolveSessionStorage("session"),
|
|
122
|
+
memory: createMemorySessionStorage(),
|
|
123
|
+
};
|
|
124
|
+
this.#defaultStorageType = configured;
|
|
125
|
+
this.#activeStorage = this.#builtIn[configured];
|
|
126
|
+
this.#activeStorageType = configured;
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
this.#customStorage = configured;
|
|
130
|
+
this.#builtIn = null;
|
|
131
|
+
this.#defaultStorageType = "local";
|
|
132
|
+
this.#activeStorage = configured;
|
|
133
|
+
this.#activeStorageType = "custom";
|
|
134
|
+
}
|
|
99
135
|
this.#hydrate();
|
|
100
136
|
}
|
|
101
|
-
/**
|
|
102
|
-
|
|
103
|
-
|
|
137
|
+
/** Try to parse a payload and validate shape + expiry. Returns the state
|
|
138
|
+
* on success, or `null` (and deletes the stored blob) on any failure. */
|
|
139
|
+
#readCandidate(storage) {
|
|
140
|
+
const raw = storage.get(this.#storageKey);
|
|
104
141
|
if (!raw)
|
|
105
|
-
return;
|
|
142
|
+
return null;
|
|
106
143
|
try {
|
|
107
144
|
const parsed = JSON.parse(raw);
|
|
108
|
-
// Basic shape check.
|
|
109
145
|
if (typeof parsed !== "object" ||
|
|
110
146
|
parsed === null ||
|
|
111
147
|
typeof parsed.status !== "string") {
|
|
112
|
-
|
|
113
|
-
return;
|
|
148
|
+
storage.del(this.#storageKey);
|
|
149
|
+
return null;
|
|
114
150
|
}
|
|
115
|
-
// Expiry check (only meaningful when expiresAt is set).
|
|
116
151
|
if (parsed.expiresAt !== null &&
|
|
117
152
|
parsed.expiresAt !== undefined &&
|
|
118
153
|
parsed.expiresAt * 1000 <= Date.now()) {
|
|
119
|
-
|
|
120
|
-
return;
|
|
154
|
+
storage.del(this.#storageKey);
|
|
155
|
+
return null;
|
|
121
156
|
}
|
|
122
|
-
|
|
157
|
+
return parsed;
|
|
123
158
|
}
|
|
124
159
|
catch {
|
|
125
|
-
|
|
160
|
+
storage.del(this.#storageKey);
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/** Read from storage and populate the store. Expired sessions are wiped.
|
|
165
|
+
* In the built-in case, probes `local → session → memory` and wipes
|
|
166
|
+
* the losing backends so stale blobs can't leak back in. */
|
|
167
|
+
#hydrate() {
|
|
168
|
+
if (this.#customStorage) {
|
|
169
|
+
const parsed = this.#readCandidate(this.#customStorage);
|
|
170
|
+
if (parsed)
|
|
171
|
+
this.#store.set(parsed);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const builtIn = this.#builtIn;
|
|
175
|
+
for (const type of BUILT_IN_ORDER) {
|
|
176
|
+
const parsed = this.#readCandidate(builtIn[type]);
|
|
177
|
+
if (!parsed)
|
|
178
|
+
continue;
|
|
179
|
+
// Adopt this backend; wipe the others so a later login-with-toggle
|
|
180
|
+
// can't accidentally re-hydrate a stale blob.
|
|
181
|
+
for (const other of BUILT_IN_ORDER) {
|
|
182
|
+
if (other !== type)
|
|
183
|
+
builtIn[other].del(this.#storageKey);
|
|
184
|
+
}
|
|
185
|
+
this.#activeStorage = builtIn[type];
|
|
186
|
+
this.#activeStorageType = type;
|
|
187
|
+
this.#store.set(parsed);
|
|
188
|
+
return;
|
|
126
189
|
}
|
|
127
190
|
}
|
|
128
191
|
#persist() {
|
|
129
192
|
const s = this.#store.get();
|
|
130
193
|
if (s.status === "anonymous") {
|
|
131
|
-
this.#
|
|
194
|
+
this.#activeStorage.del(this.#storageKey);
|
|
132
195
|
}
|
|
133
196
|
else {
|
|
134
|
-
this.#
|
|
197
|
+
this.#activeStorage.set(this.#storageKey, JSON.stringify(s));
|
|
135
198
|
}
|
|
136
199
|
}
|
|
137
200
|
#emitChange() {
|
|
@@ -163,9 +226,26 @@ export class SessionManager {
|
|
|
163
226
|
return this.#store.get().jwt;
|
|
164
227
|
}
|
|
165
228
|
/** Transition to authenticated. Called after login/register/OAuth succeed
|
|
166
|
-
* and the subject has been loaded.
|
|
229
|
+
* and the subject has been loaded.
|
|
230
|
+
*
|
|
231
|
+
* When `opts.storage` is one of `"local"` / `"session"` / `"memory"`,
|
|
232
|
+
* pins this session to that built-in backend; subsequent
|
|
233
|
+
* `patchSubject` / `setUnverified` writes land on the same backend.
|
|
234
|
+
* The previously-active backend's blob is wiped as part of the switch
|
|
235
|
+
* so "Remember me" toggles don't leave stale data behind.
|
|
236
|
+
*
|
|
237
|
+
* Ignored when the manager was constructed with a custom `SessionStorage`
|
|
238
|
+
* object (single backend) or when `opts.storage` is itself an object
|
|
239
|
+
* (no multi-backend custom storage by design). */
|
|
167
240
|
setAuthenticated(opts) {
|
|
168
|
-
const { jwt, subject, expiresAt = null } = opts;
|
|
241
|
+
const { jwt, subject, expiresAt = null, storage } = opts;
|
|
242
|
+
if (typeof storage === "string" && this.#builtIn) {
|
|
243
|
+
if (this.#activeStorage !== this.#builtIn[storage]) {
|
|
244
|
+
this.#activeStorage.del(this.#storageKey);
|
|
245
|
+
this.#activeStorage = this.#builtIn[storage];
|
|
246
|
+
this.#activeStorageType = storage;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
169
249
|
this.#store.set({
|
|
170
250
|
status: "authenticated",
|
|
171
251
|
subject,
|
|
@@ -194,15 +274,32 @@ export class SessionManager {
|
|
|
194
274
|
this.#persist();
|
|
195
275
|
this.#emitChange();
|
|
196
276
|
}
|
|
197
|
-
/** Drop the session.
|
|
198
|
-
*
|
|
277
|
+
/** Drop the session. Every built-in backend (local + session + memory)
|
|
278
|
+
* is wiped — not just the active one — so stale blobs from a previous
|
|
279
|
+
* "Remember me" toggle can't leak back in on the next construction.
|
|
280
|
+
* Resets the active backend to the manager's configured default.
|
|
281
|
+
* Downstream domains should be reset by the suite orchestrator. */
|
|
199
282
|
clear() {
|
|
283
|
+
this.#wipeAllBackends();
|
|
284
|
+
if (this.#builtIn) {
|
|
285
|
+
this.#activeStorage = this.#builtIn[this.#defaultStorageType];
|
|
286
|
+
this.#activeStorageType = this.#defaultStorageType;
|
|
287
|
+
}
|
|
200
288
|
if (this.#store.get().status === "anonymous")
|
|
201
289
|
return;
|
|
202
290
|
this.#store.set({ ...EMPTY });
|
|
203
|
-
this.#persist();
|
|
204
291
|
this.#emitChange();
|
|
205
292
|
}
|
|
293
|
+
#wipeAllBackends() {
|
|
294
|
+
if (this.#customStorage) {
|
|
295
|
+
this.#customStorage.del(this.#storageKey);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const builtIn = this.#builtIn;
|
|
299
|
+
for (const type of BUILT_IN_ORDER) {
|
|
300
|
+
builtIn[type].del(this.#storageKey);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
206
303
|
/** Patch the subject in place without touching the JWT. Used when the
|
|
207
304
|
* profile manager changes email / linked providers / etc. */
|
|
208
305
|
patchSubject(patch) {
|
|
@@ -217,10 +314,10 @@ export class SessionManager {
|
|
|
217
314
|
this.#persist();
|
|
218
315
|
this.#emitChange();
|
|
219
316
|
}
|
|
220
|
-
/** Test / reset hook — clears
|
|
317
|
+
/** Test / reset hook — clears every backend AND in-memory state without
|
|
221
318
|
* emitting. Used by teardown. */
|
|
222
319
|
destroy() {
|
|
223
|
-
this.#
|
|
320
|
+
this.#wipeAllBackends();
|
|
224
321
|
this.#store.set({ ...EMPTY });
|
|
225
322
|
}
|
|
226
323
|
}
|
package/dist/types/auth.d.ts
CHANGED
|
@@ -79,6 +79,26 @@ export interface OAuthInitOptions {
|
|
|
79
79
|
* whether to open a popup and wait for a postMessage, or redirect the
|
|
80
80
|
* top window. */
|
|
81
81
|
mode?: "popup" | "redirect";
|
|
82
|
+
/** Same semantics as {@link AuthActionOptions.remember} — pins the
|
|
83
|
+
* resulting session to `localStorage` (`true`) or `sessionStorage`
|
|
84
|
+
* (`false`). Only meaningful for `action: "login"`. */
|
|
85
|
+
remember?: boolean;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Options accepted by `AuthManager.login` / `register` /
|
|
89
|
+
* `handleOAuthCallback` to express per-login storage preference
|
|
90
|
+
* ("Remember me").
|
|
91
|
+
*/
|
|
92
|
+
export interface AuthActionOptions {
|
|
93
|
+
/** `true` → persist the resulting session to `localStorage` (survives
|
|
94
|
+
* browser restart).
|
|
95
|
+
* `false` → persist to `sessionStorage` (dies with the tab).
|
|
96
|
+
* `undefined` → use the `SessionManager`'s configured default backend.
|
|
97
|
+
*
|
|
98
|
+
* Silently ignored when the `SessionManager` was constructed with a
|
|
99
|
+
* custom `SessionStorage` object — the single custom backend is used
|
|
100
|
+
* regardless. */
|
|
101
|
+
remember?: boolean;
|
|
82
102
|
}
|
|
83
103
|
/**
|
|
84
104
|
* Uniform result shape for `register` / `login` / OAuth success. When the
|