@marianmeres/ownsuite 1.0.3 → 2.1.0
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 +206 -21
- package/API.md +410 -18
- package/README.md +86 -2
- package/dist/adapters/mock-auth.d.ts +38 -0
- package/dist/adapters/mock-auth.js +237 -0
- package/dist/adapters/mock.d.ts +10 -3
- package/dist/adapters/mock.js +79 -25
- package/dist/adapters/mod.d.ts +2 -0
- package/dist/adapters/mod.js +2 -0
- package/dist/adapters/stack-account.d.ts +38 -0
- package/dist/adapters/stack-account.js +149 -0
- package/dist/domains/auth.d.ts +83 -0
- package/dist/domains/auth.js +211 -0
- package/dist/domains/base.d.ts +66 -10
- package/dist/domains/base.js +165 -13
- package/dist/domains/mod.d.ts +3 -0
- package/dist/domains/mod.js +3 -0
- package/dist/domains/owned-collection.d.ts +29 -4
- package/dist/domains/owned-collection.js +240 -120
- package/dist/domains/profile.d.ts +62 -0
- package/dist/domains/profile.js +170 -0
- package/dist/domains/session.d.ts +73 -0
- package/dist/domains/session.js +226 -0
- package/dist/mod.d.ts +1 -0
- package/dist/mod.js +1 -0
- package/dist/oauth/popup.d.ts +64 -0
- package/dist/oauth/popup.js +104 -0
- package/dist/ownsuite.d.ts +58 -5
- package/dist/ownsuite.js +178 -10
- package/dist/types/adapter.d.ts +4 -0
- package/dist/types/auth.d.ts +162 -0
- package/dist/types/auth.js +17 -0
- package/dist/types/events.d.ts +41 -2
- package/dist/types/mod.d.ts +1 -0
- package/dist/types/mod.js +1 -0
- package/dist/types/state.d.ts +17 -0
- package/docs/future-improvements.md +81 -0
- package/package.json +15 -6
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module domains/profile
|
|
3
|
+
*
|
|
4
|
+
* ProfileManager — a singleton (one-row) reactive container for the
|
|
5
|
+
* authenticated subject's `/me` data: email, roles, verification flag,
|
|
6
|
+
* whether the account has a password, and the list of linked OAuth
|
|
7
|
+
* connections.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately NOT an OwnedCollectionManager. The underlying `/me` endpoint
|
|
10
|
+
* is a single-record resource: no list, no optimistic create/delete, and
|
|
11
|
+
* update returns the full profile. Shoehorning it into the collection
|
|
12
|
+
* manager would leak CRUD semantics that don't apply and complicate
|
|
13
|
+
* ownership semantics.
|
|
14
|
+
*
|
|
15
|
+
* This manager mutates the companion `SessionManager`'s subject in place
|
|
16
|
+
* when the profile changes (e.g. email edited) so consumers reading from
|
|
17
|
+
* `suite.session` see the update without a second fetch.
|
|
18
|
+
*/
|
|
19
|
+
import { createStore } from "@marianmeres/store";
|
|
20
|
+
import { createPubSub } from "@marianmeres/pubsub";
|
|
21
|
+
const EMPTY = {
|
|
22
|
+
profile: null,
|
|
23
|
+
loading: false,
|
|
24
|
+
error: null,
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Profile manager — singleton state for `/me`.
|
|
28
|
+
*/
|
|
29
|
+
export class ProfileManager {
|
|
30
|
+
#store;
|
|
31
|
+
#pubsub;
|
|
32
|
+
#adapter;
|
|
33
|
+
#session;
|
|
34
|
+
#context;
|
|
35
|
+
/** Currently-active read controller, for abort-supersede semantics. */
|
|
36
|
+
#readController = null;
|
|
37
|
+
constructor(options) {
|
|
38
|
+
this.#adapter = options.adapter;
|
|
39
|
+
this.#session = options.session;
|
|
40
|
+
this.#pubsub = options.pubsub ?? createPubSub();
|
|
41
|
+
this.#context = options.context ?? {};
|
|
42
|
+
this.#store = createStore({ ...EMPTY });
|
|
43
|
+
}
|
|
44
|
+
get subscribe() {
|
|
45
|
+
return this.#store.subscribe;
|
|
46
|
+
}
|
|
47
|
+
get() {
|
|
48
|
+
return this.#store.get();
|
|
49
|
+
}
|
|
50
|
+
setContext(ctx) {
|
|
51
|
+
this.#context = { ...this.#context, ...ctx };
|
|
52
|
+
}
|
|
53
|
+
replaceContext(ctx) {
|
|
54
|
+
this.#context = { ...ctx };
|
|
55
|
+
}
|
|
56
|
+
/** Build a per-op context with the current JWT from the session. */
|
|
57
|
+
#ctxFor(signal) {
|
|
58
|
+
const jwt = this.#session.getJwt();
|
|
59
|
+
return {
|
|
60
|
+
...this.#context,
|
|
61
|
+
...(jwt ? { jwt } : {}),
|
|
62
|
+
signal,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
#abortActiveRead(reason = "superseded") {
|
|
66
|
+
if (this.#readController) {
|
|
67
|
+
try {
|
|
68
|
+
this.#readController.abort(reason);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// ignore
|
|
72
|
+
}
|
|
73
|
+
this.#readController = null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Fetch `/me`. Supersedes any in-flight fetch. */
|
|
77
|
+
async fetch() {
|
|
78
|
+
this.#abortActiveRead();
|
|
79
|
+
const ctrl = new AbortController();
|
|
80
|
+
this.#readController = ctrl;
|
|
81
|
+
this.#store.update((s) => ({ ...s, loading: true }));
|
|
82
|
+
try {
|
|
83
|
+
const profile = await this.#adapter.get(this.#ctxFor(ctrl.signal));
|
|
84
|
+
if (ctrl.signal.aborted) {
|
|
85
|
+
// A newer request already took over; don't overwrite its data.
|
|
86
|
+
throw new Error("aborted");
|
|
87
|
+
}
|
|
88
|
+
this.#store.set({
|
|
89
|
+
profile,
|
|
90
|
+
loading: false,
|
|
91
|
+
error: null,
|
|
92
|
+
});
|
|
93
|
+
// Keep the session's subject in sync with /me so consumers reading
|
|
94
|
+
// from session don't need to also subscribe to profile.
|
|
95
|
+
this.#session.patchSubject({
|
|
96
|
+
email: profile.email,
|
|
97
|
+
roles: profile.roles,
|
|
98
|
+
isVerified: profile.isVerified,
|
|
99
|
+
hasPassword: profile.hasPassword,
|
|
100
|
+
});
|
|
101
|
+
return profile;
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
if (!ctrl.signal.aborted) {
|
|
105
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
106
|
+
this.#store.update((s) => ({ ...s, loading: false, error: err }));
|
|
107
|
+
}
|
|
108
|
+
throw e;
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
if (this.#readController === ctrl)
|
|
112
|
+
this.#readController = null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** Update profile (currently: email). Triggers a re-verification email
|
|
116
|
+
* server-side when the gate is on. Returns the refreshed profile. */
|
|
117
|
+
async update(input) {
|
|
118
|
+
const ctrl = new AbortController();
|
|
119
|
+
this.#store.update((s) => ({ ...s, loading: true }));
|
|
120
|
+
try {
|
|
121
|
+
const profile = await this.#adapter.update(input, this.#ctxFor(ctrl.signal));
|
|
122
|
+
this.#store.set({ profile, loading: false, error: null });
|
|
123
|
+
this.#session.patchSubject({
|
|
124
|
+
email: profile.email,
|
|
125
|
+
roles: profile.roles,
|
|
126
|
+
isVerified: profile.isVerified,
|
|
127
|
+
hasPassword: profile.hasPassword,
|
|
128
|
+
});
|
|
129
|
+
this.#pubsub.publish("profile:updated", {
|
|
130
|
+
type: "profile:updated",
|
|
131
|
+
timestamp: Date.now(),
|
|
132
|
+
email: profile.email,
|
|
133
|
+
});
|
|
134
|
+
return profile;
|
|
135
|
+
}
|
|
136
|
+
catch (e) {
|
|
137
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
138
|
+
this.#store.update((s) => ({ ...s, loading: false, error: err }));
|
|
139
|
+
throw e;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
async listOAuth() {
|
|
143
|
+
const ctrl = new AbortController();
|
|
144
|
+
return await this.#adapter.listOAuth(this.#ctxFor(ctrl.signal));
|
|
145
|
+
}
|
|
146
|
+
async unlinkOAuth(provider) {
|
|
147
|
+
const ctrl = new AbortController();
|
|
148
|
+
await this.#adapter.unlinkOAuth(provider, this.#ctxFor(ctrl.signal));
|
|
149
|
+
this.#pubsub.publish("oauth:unlinked", {
|
|
150
|
+
type: "oauth:unlinked",
|
|
151
|
+
timestamp: Date.now(),
|
|
152
|
+
provider,
|
|
153
|
+
});
|
|
154
|
+
// Refresh so the profile reflects the new connection list.
|
|
155
|
+
try {
|
|
156
|
+
await this.fetch();
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// swallow — caller already got a successful unlink
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
reset() {
|
|
163
|
+
this.#abortActiveRead("reset");
|
|
164
|
+
this.#store.set({ ...EMPTY });
|
|
165
|
+
}
|
|
166
|
+
destroy() {
|
|
167
|
+
this.#abortActiveRead("destroyed");
|
|
168
|
+
this.#store.set({ ...EMPTY });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module domains/session
|
|
3
|
+
*
|
|
4
|
+
* SessionManager — source of truth for the JWT and the authenticated subject.
|
|
5
|
+
*
|
|
6
|
+
* This is NOT an `OwnedCollectionManager` — there's no list-of-rows and no
|
|
7
|
+
* remote adapter. The session is a single reactive record, persisted via a
|
|
8
|
+
* pluggable `SessionStorage`, that drives every other manager (its JWT and
|
|
9
|
+
* subjectId end up on `OwnsuiteContext`).
|
|
10
|
+
*
|
|
11
|
+
* Consumers subscribe directly (`suite.session.subscribe(fn)`) or read the
|
|
12
|
+
* current snapshot (`suite.session.get()`). Writes go through the high-
|
|
13
|
+
* level `AuthManager` methods (`login`, `logout`, etc.) which call into
|
|
14
|
+
* this manager.
|
|
15
|
+
*/
|
|
16
|
+
import { type StoreLike } from "@marianmeres/store";
|
|
17
|
+
import { type PubSub } from "@marianmeres/pubsub";
|
|
18
|
+
import type { SessionState, SessionStorage, SessionStorageType, SessionSubject } from "../types/auth.js";
|
|
19
|
+
/** In-memory session storage — survives the current JS realm only. */
|
|
20
|
+
export declare function createMemorySessionStorage(): SessionStorage;
|
|
21
|
+
/** Resolve a `SessionStorageType` union to a concrete `SessionStorage`.
|
|
22
|
+
* Gracefully falls back to memory storage when running in an environment
|
|
23
|
+
* without Web Storage (SSR, worker without storage, etc). */
|
|
24
|
+
export declare function resolveSessionStorage(type?: SessionStorageType): SessionStorage;
|
|
25
|
+
export interface SessionManagerOptions {
|
|
26
|
+
/** Storage backend for session persistence. Default: "local". */
|
|
27
|
+
storage?: SessionStorageType;
|
|
28
|
+
/** Key used in the storage backend. Default: "ownsuite:session". */
|
|
29
|
+
storageKey?: string;
|
|
30
|
+
/** Shared pubsub for event emission. When omitted, a private one is
|
|
31
|
+
* created — useful for standalone testing of the manager. */
|
|
32
|
+
pubsub?: PubSub;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Session manager — pure reactive state + persistence, no HTTP.
|
|
36
|
+
*
|
|
37
|
+
* Writes are driven by the AuthManager. On construction it hydrates from
|
|
38
|
+
* storage; if the stored JWT has expired, it transitions to anonymous and
|
|
39
|
+
* clears the storage.
|
|
40
|
+
*/
|
|
41
|
+
export declare class SessionManager {
|
|
42
|
+
#private;
|
|
43
|
+
constructor(options?: SessionManagerOptions);
|
|
44
|
+
/** Svelte-compatible subscribe. */
|
|
45
|
+
get subscribe(): StoreLike<SessionState>["subscribe"];
|
|
46
|
+
/** Current session state snapshot. */
|
|
47
|
+
get(): SessionState;
|
|
48
|
+
get isAuthenticated(): boolean;
|
|
49
|
+
get isUnverified(): boolean;
|
|
50
|
+
get isAnonymous(): boolean;
|
|
51
|
+
/** JWT for adapter `Authorization` headers, or null when anonymous. */
|
|
52
|
+
getJwt(): string | null;
|
|
53
|
+
/** Transition to authenticated. Called after login/register/OAuth succeed
|
|
54
|
+
* and the subject has been loaded. */
|
|
55
|
+
setAuthenticated(opts: {
|
|
56
|
+
jwt: string;
|
|
57
|
+
subject: SessionSubject;
|
|
58
|
+
expiresAt?: number | null;
|
|
59
|
+
}): void;
|
|
60
|
+
/** Server confirmed credentials but refuses login until email is
|
|
61
|
+
* verified. Expose the email so the UI can prompt "check your inbox"
|
|
62
|
+
* without a second server call. No JWT in this state. */
|
|
63
|
+
setUnverified(email: string): void;
|
|
64
|
+
/** Drop the session. Storage is cleared; downstream domains should be
|
|
65
|
+
* reset by the suite orchestrator. */
|
|
66
|
+
clear(): void;
|
|
67
|
+
/** Patch the subject in place without touching the JWT. Used when the
|
|
68
|
+
* profile manager changes email / linked providers / etc. */
|
|
69
|
+
patchSubject(patch: Partial<SessionSubject>): void;
|
|
70
|
+
/** Test / reset hook — clears storage AND in-memory state without
|
|
71
|
+
* emitting. Used by teardown. */
|
|
72
|
+
destroy(): void;
|
|
73
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module domains/session
|
|
3
|
+
*
|
|
4
|
+
* SessionManager — source of truth for the JWT and the authenticated subject.
|
|
5
|
+
*
|
|
6
|
+
* This is NOT an `OwnedCollectionManager` — there's no list-of-rows and no
|
|
7
|
+
* remote adapter. The session is a single reactive record, persisted via a
|
|
8
|
+
* pluggable `SessionStorage`, that drives every other manager (its JWT and
|
|
9
|
+
* subjectId end up on `OwnsuiteContext`).
|
|
10
|
+
*
|
|
11
|
+
* Consumers subscribe directly (`suite.session.subscribe(fn)`) or read the
|
|
12
|
+
* current snapshot (`suite.session.get()`). Writes go through the high-
|
|
13
|
+
* level `AuthManager` methods (`login`, `logout`, etc.) which call into
|
|
14
|
+
* this manager.
|
|
15
|
+
*/
|
|
16
|
+
import { createStore } from "@marianmeres/store";
|
|
17
|
+
import { createPubSub } from "@marianmeres/pubsub";
|
|
18
|
+
const DEFAULT_STORAGE_KEY = "ownsuite:session";
|
|
19
|
+
const EMPTY = {
|
|
20
|
+
status: "anonymous",
|
|
21
|
+
subject: null,
|
|
22
|
+
jwt: null,
|
|
23
|
+
expiresAt: null,
|
|
24
|
+
};
|
|
25
|
+
/** In-memory session storage — survives the current JS realm only. */
|
|
26
|
+
export function createMemorySessionStorage() {
|
|
27
|
+
const map = new Map();
|
|
28
|
+
return {
|
|
29
|
+
get(k) {
|
|
30
|
+
return map.has(k) ? map.get(k) : null;
|
|
31
|
+
},
|
|
32
|
+
set(k, v) {
|
|
33
|
+
map.set(k, v);
|
|
34
|
+
},
|
|
35
|
+
del(k) {
|
|
36
|
+
map.delete(k);
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** Resolve a `SessionStorageType` union to a concrete `SessionStorage`.
|
|
41
|
+
* Gracefully falls back to memory storage when running in an environment
|
|
42
|
+
* without Web Storage (SSR, worker without storage, etc). */
|
|
43
|
+
export function resolveSessionStorage(type = "local") {
|
|
44
|
+
if (typeof type === "object" && type !== null)
|
|
45
|
+
return type;
|
|
46
|
+
if (type === "memory")
|
|
47
|
+
return createMemorySessionStorage();
|
|
48
|
+
const backend = type === "session"
|
|
49
|
+
? "sessionStorage"
|
|
50
|
+
: "localStorage";
|
|
51
|
+
const g = globalThis;
|
|
52
|
+
const store = g[backend];
|
|
53
|
+
if (!store)
|
|
54
|
+
return createMemorySessionStorage();
|
|
55
|
+
return {
|
|
56
|
+
get(k) {
|
|
57
|
+
try {
|
|
58
|
+
return store.getItem(k);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
set(k, v) {
|
|
65
|
+
try {
|
|
66
|
+
store.setItem(k, v);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// quota exceeded / disabled — degrade silently
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
del(k) {
|
|
73
|
+
try {
|
|
74
|
+
store.removeItem(k);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// ignore
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Session manager — pure reactive state + persistence, no HTTP.
|
|
84
|
+
*
|
|
85
|
+
* Writes are driven by the AuthManager. On construction it hydrates from
|
|
86
|
+
* storage; if the stored JWT has expired, it transitions to anonymous and
|
|
87
|
+
* clears the storage.
|
|
88
|
+
*/
|
|
89
|
+
export class SessionManager {
|
|
90
|
+
#store;
|
|
91
|
+
#pubsub;
|
|
92
|
+
#storage;
|
|
93
|
+
#storageKey;
|
|
94
|
+
constructor(options = {}) {
|
|
95
|
+
this.#pubsub = options.pubsub ?? createPubSub();
|
|
96
|
+
this.#storage = resolveSessionStorage(options.storage ?? "local");
|
|
97
|
+
this.#storageKey = options.storageKey ?? DEFAULT_STORAGE_KEY;
|
|
98
|
+
this.#store = createStore({ ...EMPTY });
|
|
99
|
+
this.#hydrate();
|
|
100
|
+
}
|
|
101
|
+
/** Read from storage and populate the store. Expired sessions are wiped. */
|
|
102
|
+
#hydrate() {
|
|
103
|
+
const raw = this.#storage.get(this.#storageKey);
|
|
104
|
+
if (!raw)
|
|
105
|
+
return;
|
|
106
|
+
try {
|
|
107
|
+
const parsed = JSON.parse(raw);
|
|
108
|
+
// Basic shape check.
|
|
109
|
+
if (typeof parsed !== "object" ||
|
|
110
|
+
parsed === null ||
|
|
111
|
+
typeof parsed.status !== "string") {
|
|
112
|
+
this.#storage.del(this.#storageKey);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
// Expiry check (only meaningful when expiresAt is set).
|
|
116
|
+
if (parsed.expiresAt !== null &&
|
|
117
|
+
parsed.expiresAt !== undefined &&
|
|
118
|
+
parsed.expiresAt * 1000 <= Date.now()) {
|
|
119
|
+
this.#storage.del(this.#storageKey);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
this.#store.set(parsed);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
this.#storage.del(this.#storageKey);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
#persist() {
|
|
129
|
+
const s = this.#store.get();
|
|
130
|
+
if (s.status === "anonymous") {
|
|
131
|
+
this.#storage.del(this.#storageKey);
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
this.#storage.set(this.#storageKey, JSON.stringify(s));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
#emitChange() {
|
|
138
|
+
this.#pubsub.publish("auth:session:changed", {
|
|
139
|
+
type: "auth:session:changed",
|
|
140
|
+
timestamp: Date.now(),
|
|
141
|
+
session: this.#store.get(),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
/** Svelte-compatible subscribe. */
|
|
145
|
+
get subscribe() {
|
|
146
|
+
return this.#store.subscribe;
|
|
147
|
+
}
|
|
148
|
+
/** Current session state snapshot. */
|
|
149
|
+
get() {
|
|
150
|
+
return this.#store.get();
|
|
151
|
+
}
|
|
152
|
+
get isAuthenticated() {
|
|
153
|
+
return this.#store.get().status === "authenticated";
|
|
154
|
+
}
|
|
155
|
+
get isUnverified() {
|
|
156
|
+
return this.#store.get().status === "unverified";
|
|
157
|
+
}
|
|
158
|
+
get isAnonymous() {
|
|
159
|
+
return this.#store.get().status === "anonymous";
|
|
160
|
+
}
|
|
161
|
+
/** JWT for adapter `Authorization` headers, or null when anonymous. */
|
|
162
|
+
getJwt() {
|
|
163
|
+
return this.#store.get().jwt;
|
|
164
|
+
}
|
|
165
|
+
/** Transition to authenticated. Called after login/register/OAuth succeed
|
|
166
|
+
* and the subject has been loaded. */
|
|
167
|
+
setAuthenticated(opts) {
|
|
168
|
+
const { jwt, subject, expiresAt = null } = opts;
|
|
169
|
+
this.#store.set({
|
|
170
|
+
status: "authenticated",
|
|
171
|
+
subject,
|
|
172
|
+
jwt,
|
|
173
|
+
expiresAt,
|
|
174
|
+
});
|
|
175
|
+
this.#persist();
|
|
176
|
+
this.#emitChange();
|
|
177
|
+
}
|
|
178
|
+
/** Server confirmed credentials but refuses login until email is
|
|
179
|
+
* verified. Expose the email so the UI can prompt "check your inbox"
|
|
180
|
+
* without a second server call. No JWT in this state. */
|
|
181
|
+
setUnverified(email) {
|
|
182
|
+
this.#store.set({
|
|
183
|
+
status: "unverified",
|
|
184
|
+
subject: {
|
|
185
|
+
id: "",
|
|
186
|
+
email,
|
|
187
|
+
roles: [],
|
|
188
|
+
isVerified: false,
|
|
189
|
+
hasPassword: false,
|
|
190
|
+
},
|
|
191
|
+
jwt: null,
|
|
192
|
+
expiresAt: null,
|
|
193
|
+
});
|
|
194
|
+
this.#persist();
|
|
195
|
+
this.#emitChange();
|
|
196
|
+
}
|
|
197
|
+
/** Drop the session. Storage is cleared; downstream domains should be
|
|
198
|
+
* reset by the suite orchestrator. */
|
|
199
|
+
clear() {
|
|
200
|
+
if (this.#store.get().status === "anonymous")
|
|
201
|
+
return;
|
|
202
|
+
this.#store.set({ ...EMPTY });
|
|
203
|
+
this.#persist();
|
|
204
|
+
this.#emitChange();
|
|
205
|
+
}
|
|
206
|
+
/** Patch the subject in place without touching the JWT. Used when the
|
|
207
|
+
* profile manager changes email / linked providers / etc. */
|
|
208
|
+
patchSubject(patch) {
|
|
209
|
+
const s = this.#store.get();
|
|
210
|
+
if (!s.subject)
|
|
211
|
+
return;
|
|
212
|
+
const next = {
|
|
213
|
+
...s,
|
|
214
|
+
subject: { ...s.subject, ...patch },
|
|
215
|
+
};
|
|
216
|
+
this.#store.set(next);
|
|
217
|
+
this.#persist();
|
|
218
|
+
this.#emitChange();
|
|
219
|
+
}
|
|
220
|
+
/** Test / reset hook — clears storage AND in-memory state without
|
|
221
|
+
* emitting. Used by teardown. */
|
|
222
|
+
destroy() {
|
|
223
|
+
this.#storage.del(this.#storageKey);
|
|
224
|
+
this.#store.set({ ...EMPTY });
|
|
225
|
+
}
|
|
226
|
+
}
|
package/dist/mod.d.ts
CHANGED
package/dist/mod.js
CHANGED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module oauth/popup
|
|
3
|
+
*
|
|
4
|
+
* `openOAuthPopup(url)` — opens a popup window at the OAuth init URL and
|
|
5
|
+
* resolves with the postMessage the server's callback page posts back.
|
|
6
|
+
*
|
|
7
|
+
* Server shape (stack-account): the OAuth callback page renders a `<script>`
|
|
8
|
+
* that does `window.opener.postMessage({ type, jwt, email, ... }, "*")`.
|
|
9
|
+
* We listen for those messages, validate the `type` field, and resolve.
|
|
10
|
+
*
|
|
11
|
+
* For tests, an injectable window-like interface lets us shim `postMessage`
|
|
12
|
+
* via a MessageChannel without a real browser.
|
|
13
|
+
*/
|
|
14
|
+
export interface OAuthPopupLoginMessage {
|
|
15
|
+
type: "oauth_login_success";
|
|
16
|
+
jwt: string;
|
|
17
|
+
email: string;
|
|
18
|
+
roles?: string[];
|
|
19
|
+
isNewAccount?: boolean;
|
|
20
|
+
redirectUrl?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface OAuthPopupLinkMessage {
|
|
23
|
+
type: "oauth_link_success";
|
|
24
|
+
provider: string;
|
|
25
|
+
}
|
|
26
|
+
export interface OAuthPopupErrorMessage {
|
|
27
|
+
type: "oauth_error";
|
|
28
|
+
error: string;
|
|
29
|
+
}
|
|
30
|
+
export type OAuthPopupMessage = OAuthPopupLoginMessage | OAuthPopupLinkMessage;
|
|
31
|
+
/**
|
|
32
|
+
* Minimal window-like surface we need. Real `Window` satisfies this; tests
|
|
33
|
+
* supply a shim.
|
|
34
|
+
*/
|
|
35
|
+
export interface PopupWindowHost {
|
|
36
|
+
open(url: string, target: string, features?: string): PopupWindowHandle | null;
|
|
37
|
+
addEventListener(type: "message", listener: (event: MessageEvent) => void): void;
|
|
38
|
+
removeEventListener(type: "message", listener: (event: MessageEvent) => void): void;
|
|
39
|
+
}
|
|
40
|
+
export interface PopupWindowHandle {
|
|
41
|
+
closed: boolean;
|
|
42
|
+
focus?(): void;
|
|
43
|
+
close?(): void;
|
|
44
|
+
}
|
|
45
|
+
export interface OpenOAuthPopupOptions {
|
|
46
|
+
/** Host window — defaults to `globalThis` when running in the browser. */
|
|
47
|
+
host?: PopupWindowHost;
|
|
48
|
+
/** How often to poll for popup-closed-without-message. Default 500ms. */
|
|
49
|
+
closedPollMs?: number;
|
|
50
|
+
/** Hard timeout in ms; 0 disables. Default 0. */
|
|
51
|
+
timeoutMs?: number;
|
|
52
|
+
/** Restrict accepted message origins. Default: no origin check (the
|
|
53
|
+
* server's postMessage uses "*" intentionally; origin validation is the
|
|
54
|
+
* caller's responsibility if needed). */
|
|
55
|
+
expectedOrigin?: string;
|
|
56
|
+
/** Popup window features string. */
|
|
57
|
+
features?: string;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Open the OAuth popup at `url` and await a success message from it.
|
|
61
|
+
* Rejects if the popup is closed before a message arrives, if the received
|
|
62
|
+
* message is an error, or if the optional timeout fires.
|
|
63
|
+
*/
|
|
64
|
+
export declare function openOAuthPopup(url: string, options?: OpenOAuthPopupOptions): Promise<OAuthPopupMessage>;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module oauth/popup
|
|
3
|
+
*
|
|
4
|
+
* `openOAuthPopup(url)` — opens a popup window at the OAuth init URL and
|
|
5
|
+
* resolves with the postMessage the server's callback page posts back.
|
|
6
|
+
*
|
|
7
|
+
* Server shape (stack-account): the OAuth callback page renders a `<script>`
|
|
8
|
+
* that does `window.opener.postMessage({ type, jwt, email, ... }, "*")`.
|
|
9
|
+
* We listen for those messages, validate the `type` field, and resolve.
|
|
10
|
+
*
|
|
11
|
+
* For tests, an injectable window-like interface lets us shim `postMessage`
|
|
12
|
+
* via a MessageChannel without a real browser.
|
|
13
|
+
*/
|
|
14
|
+
const DEFAULT_FEATURES = "width=500,height=700,noopener=no,noreferrer=no";
|
|
15
|
+
/**
|
|
16
|
+
* Open the OAuth popup at `url` and await a success message from it.
|
|
17
|
+
* Rejects if the popup is closed before a message arrives, if the received
|
|
18
|
+
* message is an error, or if the optional timeout fires.
|
|
19
|
+
*/
|
|
20
|
+
export function openOAuthPopup(url, options = {}) {
|
|
21
|
+
const host = (options.host ?? globalThis);
|
|
22
|
+
if (typeof host.open !== "function" || typeof host.addEventListener !== "function") {
|
|
23
|
+
return Promise.reject(new Error("openOAuthPopup: host window does not support popups"));
|
|
24
|
+
}
|
|
25
|
+
const features = options.features ?? DEFAULT_FEATURES;
|
|
26
|
+
const closedPollMs = options.closedPollMs ?? 500;
|
|
27
|
+
const popup = host.open(url, "oauth_popup", features);
|
|
28
|
+
if (!popup) {
|
|
29
|
+
return Promise.reject(new Error("OAUTH_POPUP_BLOCKED"));
|
|
30
|
+
}
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
let settled = false;
|
|
33
|
+
let pollTimer;
|
|
34
|
+
let timeoutTimer;
|
|
35
|
+
const cleanup = () => {
|
|
36
|
+
settled = true;
|
|
37
|
+
host.removeEventListener("message", onMessage);
|
|
38
|
+
if (pollTimer !== undefined)
|
|
39
|
+
clearInterval(pollTimer);
|
|
40
|
+
if (timeoutTimer !== undefined)
|
|
41
|
+
clearTimeout(timeoutTimer);
|
|
42
|
+
};
|
|
43
|
+
const onMessage = (event) => {
|
|
44
|
+
if (settled)
|
|
45
|
+
return;
|
|
46
|
+
if (options.expectedOrigin !== undefined &&
|
|
47
|
+
event.origin !== options.expectedOrigin) {
|
|
48
|
+
return; // silently drop mismatched origin messages
|
|
49
|
+
}
|
|
50
|
+
const data = event.data;
|
|
51
|
+
if (!data || typeof data !== "object" || typeof data.type !== "string") {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (data.type === "oauth_login_success" ||
|
|
55
|
+
data.type === "oauth_link_success") {
|
|
56
|
+
cleanup();
|
|
57
|
+
try {
|
|
58
|
+
popup.close?.();
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// ignore
|
|
62
|
+
}
|
|
63
|
+
resolve(data);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (data.type === "oauth_error") {
|
|
67
|
+
cleanup();
|
|
68
|
+
try {
|
|
69
|
+
popup.close?.();
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// ignore
|
|
73
|
+
}
|
|
74
|
+
reject(new Error(data.error));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
host.addEventListener("message", onMessage);
|
|
79
|
+
if (closedPollMs > 0) {
|
|
80
|
+
pollTimer = setInterval(() => {
|
|
81
|
+
if (settled)
|
|
82
|
+
return;
|
|
83
|
+
if (popup.closed) {
|
|
84
|
+
cleanup();
|
|
85
|
+
reject(new Error("OAUTH_POPUP_CLOSED"));
|
|
86
|
+
}
|
|
87
|
+
}, closedPollMs);
|
|
88
|
+
}
|
|
89
|
+
if (options.timeoutMs && options.timeoutMs > 0) {
|
|
90
|
+
timeoutTimer = setTimeout(() => {
|
|
91
|
+
if (settled)
|
|
92
|
+
return;
|
|
93
|
+
cleanup();
|
|
94
|
+
try {
|
|
95
|
+
popup.close?.();
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// ignore
|
|
99
|
+
}
|
|
100
|
+
reject(new Error("OAUTH_POPUP_TIMEOUT"));
|
|
101
|
+
}, options.timeoutMs);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|