@andco/sdk 0.0.2 → 0.0.4
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/LICENSE +1 -1
- package/README.md +96 -54
- package/dist/auth.d.ts +8 -3
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +32 -23
- package/dist/browser/controller.d.ts +55 -1
- package/dist/browser/controller.d.ts.map +1 -1
- package/dist/browser/controller.js +166 -9
- package/dist/browser/frame.d.ts +4 -3
- package/dist/browser/frame.d.ts.map +1 -1
- package/dist/browser/frame.js +12 -20
- package/dist/browser/index.d.ts +18 -6
- package/dist/browser/index.d.ts.map +1 -1
- package/dist/browser/index.js +64 -29
- package/dist/browser/popup.d.ts +6 -1
- package/dist/browser/popup.d.ts.map +1 -1
- package/dist/browser/popup.js +37 -8
- package/dist/cli/index.d.ts +4 -2
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +10 -3
- package/dist/cli/server.d.ts +5 -0
- package/dist/cli/server.d.ts.map +1 -1
- package/dist/cli/server.js +5 -0
- package/dist/client.d.ts +66 -19
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +125 -74
- package/dist/config.d.ts +2 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -0
- package/dist/credentials.d.ts +58 -4
- package/dist/credentials.d.ts.map +1 -1
- package/dist/credentials.js +0 -0
- package/dist/errors.d.ts +23 -21
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +18 -20
- package/dist/globals.d.ts +25 -0
- package/dist/globals.d.ts.map +1 -0
- package/dist/globals.js +15 -0
- package/dist/grants-api.d.ts +34 -0
- package/dist/grants-api.d.ts.map +1 -0
- package/dist/grants-api.js +48 -0
- package/dist/grants.d.ts +16 -0
- package/dist/grants.d.ts.map +1 -0
- package/dist/grants.js +13 -0
- package/dist/index.d.ts +12 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -2
- package/dist/inflight.d.ts +31 -0
- package/dist/inflight.d.ts.map +1 -0
- package/dist/inflight.js +26 -0
- package/dist/intents.d.ts +386 -40
- package/dist/intents.d.ts.map +1 -1
- package/dist/intents.js +712 -53
- package/dist/oauth.d.ts +55 -6
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +86 -57
- package/dist/presenter.d.ts +59 -11
- package/dist/presenter.d.ts.map +1 -1
- package/dist/presenter.js +40 -1
- package/dist/resource.d.ts +109 -0
- package/dist/resource.d.ts.map +1 -0
- package/dist/resource.js +151 -0
- package/dist/rest.d.ts +23 -4
- package/dist/rest.d.ts.map +1 -1
- package/dist/rest.js +46 -5
- package/dist/server/index.d.ts +3 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +1 -0
- package/dist/server-metadata.generated.d.ts.map +1 -1
- package/dist/server-metadata.generated.js +8 -4
- package/dist/service.d.ts +109 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +241 -0
- package/dist/session-store.d.ts +12 -4
- package/dist/session-store.d.ts.map +1 -1
- package/dist/session-store.js +64 -14
- package/dist/storage.d.ts +16 -23
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +27 -25
- package/dist/tokens.d.ts +46 -0
- package/dist/tokens.d.ts.map +1 -0
- package/dist/tokens.js +148 -0
- package/package.json +13 -3
package/dist/service.js
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { ANDCO_API_ORIGIN, assertAndCoResource, parseServiceGrants, parseServiceSubject, parseServiceToken, } from "@andco/protocol";
|
|
2
|
+
import { AndcoConfig } from "./config.js";
|
|
3
|
+
import { ANDCO_REFRESH_SKEW_SECONDS } from "./credentials.js";
|
|
4
|
+
import { AndcoError } from "./errors.js";
|
|
5
|
+
import { resolveGlobals } from "./globals.js";
|
|
6
|
+
import { grantId } from "./grants.js";
|
|
7
|
+
import { AndcoIntents } from "./intents.js";
|
|
8
|
+
import { AndcoRest } from "./rest.js";
|
|
9
|
+
/** No approved Grant covers this subject and resource. Version one never opens a login flow. */
|
|
10
|
+
export class AndcoAuthorizationRequiredError extends AndcoError {
|
|
11
|
+
subject;
|
|
12
|
+
resource;
|
|
13
|
+
constructor(subject, resource) {
|
|
14
|
+
super("authorization_required", {
|
|
15
|
+
message: "No approved Grant covers this subject and resource; approve one in Platform",
|
|
16
|
+
details: { subject, resource },
|
|
17
|
+
});
|
|
18
|
+
this.subject = subject;
|
|
19
|
+
this.resource = resource;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Confidential client acting for Organizations through Grants approved in Platform.
|
|
24
|
+
*
|
|
25
|
+
* Each call sends the subject and the resource, and the Authorization Server selects the Grant.
|
|
26
|
+
* When more than one Grant qualifies the exchange fails with `ambiguous_grant`, whose `details`
|
|
27
|
+
* name the candidate ids; pass one as `grant` to pin it. Access tokens live in memory only.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```ts
|
|
31
|
+
* const service = createAndcoService({ clientId, clientSecret });
|
|
32
|
+
* const bank = new Bank(service.credentials({ subject: { type: "organization", identifier: "123" } }));
|
|
33
|
+
* const { data } = await bank.client.http.GET("/accounts").throwOnError();
|
|
34
|
+
* await service.intents({ subject: { type: "organization", identifier: "123" } }).approve(transferId);
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export class AndcoService {
|
|
38
|
+
config;
|
|
39
|
+
rest;
|
|
40
|
+
#subject;
|
|
41
|
+
#resource;
|
|
42
|
+
#secret;
|
|
43
|
+
#globals;
|
|
44
|
+
#tokens = new Map();
|
|
45
|
+
#pending = new Map();
|
|
46
|
+
constructor(options) {
|
|
47
|
+
if (typeof window !== "undefined")
|
|
48
|
+
throw new AndcoError("browser_forbidden", { message: "Service credentials cannot be used in a browser" });
|
|
49
|
+
if (typeof options.clientSecret !== "string" || !options.clientSecret)
|
|
50
|
+
throw new AndcoError("invalid_configuration", { message: "Service requires clientSecret" });
|
|
51
|
+
this.config = AndcoConfig.from(options);
|
|
52
|
+
this.#subject = options.subject === undefined ? undefined : parseSubject(options.subject);
|
|
53
|
+
this.#resource = options.resource ?? ANDCO_API_ORIGIN;
|
|
54
|
+
assertAndCoResource(this.#resource);
|
|
55
|
+
this.#secret = options.clientSecret;
|
|
56
|
+
this.#globals = resolveGlobals(options.globals);
|
|
57
|
+
this.rest = new AndcoRest({
|
|
58
|
+
config: this.config,
|
|
59
|
+
globals: this.#globals,
|
|
60
|
+
credentials: this.credentials(),
|
|
61
|
+
resource: this.#resource,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Credentials for Bank or another Resource Server definition, acting for one Organization.
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* ```ts
|
|
69
|
+
* const bank = new Bank(service.credentials({ subject: { type: "organization", identifier: "123" } }));
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
credentials(options = {}) {
|
|
73
|
+
const subject = options.subject === undefined ? this.#subject : parseSubject(options.subject);
|
|
74
|
+
const grant = options.grant === undefined ? undefined : grantId(options.grant);
|
|
75
|
+
return this.#credentials(subject, grant);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The Intent lifecycle (`approve`, `reject`, `cancel`, `get`, `events`, `subscribe`) acting for
|
|
79
|
+
* one Organization: the same {@link AndcoIntents} `andco.intents` exposes, authorized with
|
|
80
|
+
* `credentials(options)`. A method rather than a property, like `credentials()`, because the
|
|
81
|
+
* subject and the Grant are chosen per call; without options it acts for the Service's `subject`.
|
|
82
|
+
* Presentation needs a person and a browser, so `present()` and `presentationURL()` are not for a
|
|
83
|
+
* Service.
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* ```ts
|
|
87
|
+
* const intents = service.intents({ subject: { type: "organization", identifier: "123" } });
|
|
88
|
+
* await intents.approve(transferId); // the server selects the Grant holding bank_transfer approve
|
|
89
|
+
* await service.intents({ grant: approverGrantId }).reject(transferId, { reason: "over_budget" });
|
|
90
|
+
* const { data: transfer } = await service.intents().get(transferId);
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
intents(options = {}) {
|
|
94
|
+
console.debug("[AndcoService.intents] binding Intents for %o grant %s", options.subject ?? this.#subject, options.grant === undefined ? "(server)" : grantId(options.grant));
|
|
95
|
+
const rest = new AndcoRest({
|
|
96
|
+
config: this.config,
|
|
97
|
+
globals: this.#globals,
|
|
98
|
+
credentials: this.credentials(options),
|
|
99
|
+
resource: this.#resource,
|
|
100
|
+
});
|
|
101
|
+
return new AndcoIntents({ rest, config: this.config, globals: this.#globals });
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Lists the active Grants the server would select from for one subject and resource.
|
|
105
|
+
*
|
|
106
|
+
* @example
|
|
107
|
+
* ```ts
|
|
108
|
+
* const grants = await service.listGrants({ subject: { type: "organization", identifier: "123" } });
|
|
109
|
+
* const bank = new Bank(service.credentials({ subject, grant: grants[0] }));
|
|
110
|
+
* ```
|
|
111
|
+
*/
|
|
112
|
+
async listGrants(options = {}) {
|
|
113
|
+
const subject = this.#require(options.subject === undefined ? this.#subject : parseSubject(options.subject));
|
|
114
|
+
const resource = options.resource ?? this.#resource;
|
|
115
|
+
assertAndCoResource(resource);
|
|
116
|
+
const grants = parseServiceGrants(await this.#exchange("oauth/grants", parameters(subject, resource)));
|
|
117
|
+
console.debug("[AndcoService.listGrants] %s Grants for %o on %s", grants.length, subject, resource);
|
|
118
|
+
return grants;
|
|
119
|
+
}
|
|
120
|
+
#credentials(subject, fixed, pin) {
|
|
121
|
+
const credentials = {
|
|
122
|
+
config: this.config,
|
|
123
|
+
forOperation: () => this.#credentials(subject, fixed, {}),
|
|
124
|
+
accessTokenFor: async (resource, request) => {
|
|
125
|
+
assertAndCoResource(resource);
|
|
126
|
+
const target = this.#require(subject);
|
|
127
|
+
const explicit = request?.grant === undefined ? fixed : grantId(request.grant);
|
|
128
|
+
if (pin?.grant && explicit !== undefined && explicit !== pin.grant)
|
|
129
|
+
throw new AndcoError("invalid_grant", { message: "Operation authority is already pinned" });
|
|
130
|
+
const grant = explicit ?? pin?.grant;
|
|
131
|
+
const issued = await this.#token(target, resource, grant);
|
|
132
|
+
if (pin)
|
|
133
|
+
pin.grant = issued.grantId;
|
|
134
|
+
return issued.token;
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
return credentials;
|
|
138
|
+
}
|
|
139
|
+
#require(subject) {
|
|
140
|
+
if (subject === undefined)
|
|
141
|
+
throw new AndcoError("invalid_configuration", {
|
|
142
|
+
message: "Service requires a subject: pass one to createAndcoService() or service.credentials()",
|
|
143
|
+
});
|
|
144
|
+
return subject;
|
|
145
|
+
}
|
|
146
|
+
async #token(subject, resource, grant) {
|
|
147
|
+
const now = Date.now() / 1000;
|
|
148
|
+
for (const [key, cached] of this.#tokens)
|
|
149
|
+
if (cached.expiresAt <= now + ANDCO_REFRESH_SKEW_SECONDS)
|
|
150
|
+
this.#tokens.delete(key);
|
|
151
|
+
const body = parameters(subject, resource);
|
|
152
|
+
body.set("grant_type", "client_credentials");
|
|
153
|
+
if (grant !== undefined)
|
|
154
|
+
body.set("grant_id", grant);
|
|
155
|
+
const key = body.toString();
|
|
156
|
+
const cached = this.#tokens.get(key);
|
|
157
|
+
if (cached)
|
|
158
|
+
return { token: cached.token, grantId: cached.grantId };
|
|
159
|
+
const pending = this.#pending.get(key);
|
|
160
|
+
if (pending)
|
|
161
|
+
return pending;
|
|
162
|
+
const acquire = (async () => {
|
|
163
|
+
console.debug("[AndcoService.token] exchanging for %o on %s grant %s", subject, resource, grant ?? "(server)");
|
|
164
|
+
let payload;
|
|
165
|
+
try {
|
|
166
|
+
payload = await this.#exchange("oauth/token", body);
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
if (AndcoError.is(error) && error.code === "authorization_required")
|
|
170
|
+
throw new AndcoAuthorizationRequiredError(subject, resource);
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
const token = parseServiceToken(payload);
|
|
174
|
+
if (grant !== undefined && token.authorization_grant_id !== grant)
|
|
175
|
+
throw new AndcoError("invalid_response", { message: "Issuer substituted the explicit Grant" });
|
|
176
|
+
const issued = {
|
|
177
|
+
token: token.access_token,
|
|
178
|
+
expiresAt: Date.now() / 1000 + token.expires_in,
|
|
179
|
+
grantId: token.authorization_grant_id,
|
|
180
|
+
};
|
|
181
|
+
this.#tokens.set(key, issued);
|
|
182
|
+
// A server-selected token is also the token of its exact Grant, so a pinned operation reuses it.
|
|
183
|
+
const exact = new URLSearchParams(body);
|
|
184
|
+
exact.set("grant_id", token.authorization_grant_id);
|
|
185
|
+
this.#tokens.set(exact.toString(), issued);
|
|
186
|
+
console.debug("[AndcoService.token] issued with Grant %s", token.authorization_grant_id);
|
|
187
|
+
return { token: token.access_token, grantId: token.authorization_grant_id };
|
|
188
|
+
})();
|
|
189
|
+
this.#pending.set(key, acquire);
|
|
190
|
+
try {
|
|
191
|
+
return await acquire;
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
this.#pending.delete(key);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
async #exchange(path, body) {
|
|
198
|
+
const basic = btoa(`${encodeURIComponent(this.config.clientId)}:${encodeURIComponent(this.#secret)}`);
|
|
199
|
+
const response = await this.#globals.fetch(this.config.authURL(path), {
|
|
200
|
+
method: "POST",
|
|
201
|
+
headers: { Authorization: `Basic ${basic}`, "Content-Type": "application/x-www-form-urlencoded" },
|
|
202
|
+
body,
|
|
203
|
+
redirect: "error",
|
|
204
|
+
});
|
|
205
|
+
const payload = await response.json().catch((cause) => {
|
|
206
|
+
throw new AndcoError("invalid_response", {
|
|
207
|
+
status: response.status,
|
|
208
|
+
cause,
|
|
209
|
+
message: "Issuer returned invalid JSON",
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
if (!response.ok) {
|
|
213
|
+
const { error, error_description, ...details } = payload && typeof payload === "object" ? payload : {};
|
|
214
|
+
console.debug("[AndcoService.exchange] %s failed %s %s", path, response.status, error);
|
|
215
|
+
throw new AndcoError(typeof error === "string" ? error : "invalid_response", {
|
|
216
|
+
status: response.status,
|
|
217
|
+
message: typeof error_description === "string" ? error_description : undefined,
|
|
218
|
+
details: Object.keys(details).length ? details : undefined,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
return payload;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
function parseSubject(subject) {
|
|
225
|
+
try {
|
|
226
|
+
return parseServiceSubject(subject);
|
|
227
|
+
}
|
|
228
|
+
catch (cause) {
|
|
229
|
+
throw new AndcoError("invalid_configuration", {
|
|
230
|
+
cause,
|
|
231
|
+
message: 'A Service subject is { type: "organization", identifier } with an exact Organization id',
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
function parameters(subject, resource) {
|
|
236
|
+
return new URLSearchParams({ subject: JSON.stringify(subject), resource });
|
|
237
|
+
}
|
|
238
|
+
/** Constructs a service lazily; does not perform network I/O or approve access. */
|
|
239
|
+
export function createAndcoService(options) {
|
|
240
|
+
return new AndcoService(options);
|
|
241
|
+
}
|
package/dist/session-store.d.ts
CHANGED
|
@@ -1,21 +1,24 @@
|
|
|
1
1
|
import type { AndcoConfig } from "./config.js";
|
|
2
2
|
import { type AndcoCredentials, type AndcoSession } from "./credentials.js";
|
|
3
3
|
import { AndcoError, Result } from "./errors.js";
|
|
4
|
+
import { type AndcoInflight } from "./inflight.js";
|
|
4
5
|
import type { AndcoOAuth } from "./oauth.js";
|
|
5
|
-
import { type
|
|
6
|
+
import { type AndcoStorage } from "./storage.js";
|
|
6
7
|
/** Reads a session that arrived from somewhere other than storage, such as a URL callback. */
|
|
7
8
|
export type AndcoSessionSource = () => Promise<Result<AndcoSession | null>>;
|
|
9
|
+
/** Lazy session loading, persistence, refresh, and initial hydration options. */
|
|
8
10
|
export type AndcoSessionStoreOptions = {
|
|
9
11
|
config: AndcoConfig;
|
|
10
12
|
oauth: AndcoOAuth;
|
|
13
|
+
/** Persists serialized sessions. Defaults to memory for this store's lifetime. */
|
|
11
14
|
storage?: AndcoStorage;
|
|
12
15
|
/** A session the caller already resolved. Makes the first snapshot real instead of unresolved. */
|
|
13
16
|
initialSession?: AndcoSession | null;
|
|
14
|
-
/**
|
|
15
|
-
|
|
17
|
+
/** Shares one refresh among concurrent callers. Defaults to this runtime only. */
|
|
18
|
+
inflight?: AndcoInflight;
|
|
16
19
|
/** Consulted once during the load, before storage. Used for URL callbacks. */
|
|
17
20
|
source?: AndcoSessionSource;
|
|
18
|
-
/**
|
|
21
|
+
/** Writes session changes to storage unless `false`; defaults to `true`. */
|
|
19
22
|
persist?: boolean;
|
|
20
23
|
};
|
|
21
24
|
/**
|
|
@@ -71,6 +74,11 @@ export declare class AndcoSessionStore implements AndcoCredentials {
|
|
|
71
74
|
* discarded instance be inert and removes any need for instance identity.
|
|
72
75
|
*/
|
|
73
76
|
accessTokenFor(resource: string): Promise<string | null>;
|
|
77
|
+
/**
|
|
78
|
+
* Refreshes now, even if the session is still fresh. Concurrent calls, and concurrent token
|
|
79
|
+
* resolutions that find the session stale, share one token request.
|
|
80
|
+
*/
|
|
81
|
+
refresh(): Promise<Result<AndcoSession>>;
|
|
74
82
|
/**
|
|
75
83
|
* Replaces the stored session. This is the supported way to restore a saved credential, which
|
|
76
84
|
* previously required writing a JSON blob into the SDK's private storage key because no such
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"session-store.d.ts","sourceRoot":"","sources":["../src/session-store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC/C,OAAO,EAAE,KAAK,gBAAgB,EAAE,KAAK,YAAY,EAAuB,MAAM,kBAAkB,CAAC;AACjG,OAAO,EAAqB,UAAU,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AACpE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAC7C,OAAO,EAAE,KAAK,
|
|
1
|
+
{"version":3,"file":"session-store.d.ts","sourceRoot":"","sources":["../src/session-store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC/C,OAAO,EAAE,KAAK,gBAAgB,EAAE,KAAK,YAAY,EAAuB,MAAM,kBAAkB,CAAC;AACjG,OAAO,EAAqB,UAAU,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AACpE,OAAO,EAAE,KAAK,aAAa,EAAkB,MAAM,eAAe,CAAC;AACnE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAC7C,OAAO,EAAE,KAAK,YAAY,EAAiB,MAAM,cAAc,CAAC;AAEhE,8FAA8F;AAC9F,MAAM,MAAM,kBAAkB,GAAG,MAAM,OAAO,CAAC,MAAM,CAAC,YAAY,GAAG,IAAI,CAAC,CAAC,CAAC;AAE5E,iFAAiF;AACjF,MAAM,MAAM,wBAAwB,GAAG;IACrC,MAAM,EAAE,WAAW,CAAC;IACpB,KAAK,EAAE,UAAU,CAAC;IAClB,kFAAkF;IAClF,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,kGAAkG;IAClG,cAAc,CAAC,EAAE,YAAY,GAAG,IAAI,CAAC;IACrC,kFAAkF;IAClF,QAAQ,CAAC,EAAE,aAAa,CAAC;IACzB,8EAA8E;IAC9E,MAAM,CAAC,EAAE,kBAAkB,CAAC;IAC5B,4EAA4E;IAC5E,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,qBAAa,iBAAkB,YAAW,gBAAgB;;gBAWrC,OAAO,EAAE,wBAAwB;IASpD;;;;;;OAMG;IACI,WAAW,IAAI,YAAY,GAAG,IAAI,GAAG,SAAS;IAIrD,4FAA4F;IACrF,iBAAiB,IAAI,YAAY,GAAG,IAAI,GAAG,SAAS;IAI3D,sFAAsF;IAC/E,SAAS,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI;IAQlD;;;;;;OAMG;IACH,IAAW,KAAK,IAAI,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAExC;IAED;;;;;OAKG;IACU,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IAarE;;;OAGG;IACU,OAAO,IAAI,OAAO,CAAC,MAAM,CAAC,YAAY,CAAC,CAAC;IAuBrD;;;;OAIG;IACU,GAAG,CAAC,OAAO,EAAE,YAAY,GAAG,IAAI,GAAG,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;CAmFtE;AAgBD,6EAA6E;AAC7E,wBAAgB,eAAe,IAAI,UAAU,CAE5C"}
|
package/dist/session-store.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isExpired, tokenFor } from "./credentials.js";
|
|
2
2
|
import { ANDCO_ERROR_CODES, AndcoError, Result } from "./errors.js";
|
|
3
|
-
import {
|
|
3
|
+
import { MemoryInflight } from "./inflight.js";
|
|
4
|
+
import { MemoryStorage } from "./storage.js";
|
|
4
5
|
/**
|
|
5
6
|
* The credential as a resource, and the only stateful object in the SDK.
|
|
6
7
|
*
|
|
@@ -27,7 +28,7 @@ import { InProcessLock, MemoryStorage } from "./storage.js";
|
|
|
27
28
|
export class AndcoSessionStore {
|
|
28
29
|
#options;
|
|
29
30
|
#storage;
|
|
30
|
-
#
|
|
31
|
+
#inflight;
|
|
31
32
|
#listeners = new Set();
|
|
32
33
|
#key;
|
|
33
34
|
#hydrated;
|
|
@@ -36,7 +37,7 @@ export class AndcoSessionStore {
|
|
|
36
37
|
constructor(options) {
|
|
37
38
|
this.#options = options;
|
|
38
39
|
this.#storage = options.storage ?? new MemoryStorage();
|
|
39
|
-
this.#
|
|
40
|
+
this.#inflight = options.inflight ?? new MemoryInflight();
|
|
40
41
|
this.#key = `andco.session.${options.config.clientId}`;
|
|
41
42
|
this.#hydrated = options.initialSession;
|
|
42
43
|
this.#snapshot = options.initialSession;
|
|
@@ -86,23 +87,42 @@ export class AndcoSessionStore {
|
|
|
86
87
|
return null;
|
|
87
88
|
if (!isExpired(current))
|
|
88
89
|
return tokenFor(current, resource);
|
|
89
|
-
//
|
|
90
|
-
const refreshed = await this.#
|
|
91
|
-
|
|
90
|
+
// One refresh grant renews every resource, so concurrent callers share it whatever they asked for.
|
|
91
|
+
const refreshed = await this.#refresh(false);
|
|
92
|
+
if (refreshed.error)
|
|
93
|
+
return null;
|
|
94
|
+
return refreshed.data ? tokenFor(refreshed.data, resource) : null;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Refreshes now, even if the session is still fresh. Concurrent calls, and concurrent token
|
|
98
|
+
* resolutions that find the session stale, share one token request.
|
|
99
|
+
*/
|
|
100
|
+
async refresh() {
|
|
101
|
+
await this.#ensureLoaded();
|
|
102
|
+
const refreshed = await this.#refresh(true);
|
|
103
|
+
if (refreshed.error)
|
|
104
|
+
return Result.fail(refreshed.error);
|
|
105
|
+
if (!refreshed.data)
|
|
106
|
+
return Result.fail(ANDCO_ERROR_CODES.SESSION_MISSING);
|
|
107
|
+
return Result.ok(refreshed.data);
|
|
108
|
+
}
|
|
109
|
+
async #refresh(force) {
|
|
110
|
+
console.debug("[refresh] requested force=%s", force);
|
|
111
|
+
return this.#inflight.run(this.#key, async () => {
|
|
112
|
+
const latest = await this.#latest();
|
|
92
113
|
if (!latest)
|
|
93
|
-
return null;
|
|
94
|
-
// Another
|
|
95
|
-
if (!isExpired(latest))
|
|
96
|
-
return latest;
|
|
114
|
+
return Result.ok(null);
|
|
115
|
+
// Another caller, or another tab sharing the storage, may have refreshed already.
|
|
116
|
+
if (!force && !isExpired(latest))
|
|
117
|
+
return Result.ok(latest);
|
|
97
118
|
if (!latest.refreshToken)
|
|
98
|
-
return
|
|
119
|
+
return Result.fail(ANDCO_ERROR_CODES.SESSION_MISSING);
|
|
99
120
|
const result = await this.#options.oauth.refresh(latest.refreshToken);
|
|
100
121
|
if (result.error)
|
|
101
|
-
return
|
|
122
|
+
return Result.fail(result.error);
|
|
102
123
|
await this.#write(result.data);
|
|
103
|
-
return result.data;
|
|
124
|
+
return Result.ok(result.data);
|
|
104
125
|
});
|
|
105
|
-
return refreshed ? tokenFor(refreshed, resource) : null;
|
|
106
126
|
}
|
|
107
127
|
/**
|
|
108
128
|
* Replaces the stored session. This is the supported way to restore a saved credential, which
|
|
@@ -148,6 +168,36 @@ export class AndcoSessionStore {
|
|
|
148
168
|
return Result.fail(AndcoError.from(cause, ANDCO_ERROR_CODES.STORAGE_FAILED));
|
|
149
169
|
}
|
|
150
170
|
}
|
|
171
|
+
/**
|
|
172
|
+
* The newest session between memory and storage.
|
|
173
|
+
*
|
|
174
|
+
* Storage shared by several runtimes — `localStorage` across tabs — may hold a session another
|
|
175
|
+
* runtime refreshed after this one loaded. Refresh tokens rotate, so refreshing with the copy in
|
|
176
|
+
* memory would present a token already replaced, which the Authorization Server tolerates only
|
|
177
|
+
* for a short interval and otherwise treats as reuse. Storage wins only when it is newer: a
|
|
178
|
+
* hydrated session is never written, so an empty storage does not mean signed out.
|
|
179
|
+
*/
|
|
180
|
+
async #latest() {
|
|
181
|
+
const current = this.#snapshot ?? null;
|
|
182
|
+
if (this.#options.persist === false)
|
|
183
|
+
return current;
|
|
184
|
+
let persisted;
|
|
185
|
+
try {
|
|
186
|
+
const stored = await this.#storage.getItem(this.#key);
|
|
187
|
+
persisted = stored ? parseSession(stored) : null;
|
|
188
|
+
}
|
|
189
|
+
catch (cause) {
|
|
190
|
+
console.warn("[AndcoSessionStore] could not read storage before refreshing: %o", cause);
|
|
191
|
+
return current;
|
|
192
|
+
}
|
|
193
|
+
if (!persisted)
|
|
194
|
+
return current;
|
|
195
|
+
if (current && persisted.expiresAt <= current.expiresAt)
|
|
196
|
+
return current;
|
|
197
|
+
console.debug("[AndcoSessionStore] adopting a newer stored session expiring at %s", persisted.expiresAt);
|
|
198
|
+
this.#publish(persisted);
|
|
199
|
+
return persisted;
|
|
200
|
+
}
|
|
151
201
|
async #write(session) {
|
|
152
202
|
if (this.#options.persist !== false) {
|
|
153
203
|
if (session)
|
package/dist/storage.d.ts
CHANGED
|
@@ -27,35 +27,28 @@ export declare class MemoryStorage implements AndcoStorage {
|
|
|
27
27
|
setItem(key: string, value: string): void;
|
|
28
28
|
removeItem(key: string): void;
|
|
29
29
|
}
|
|
30
|
-
/**
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Keeps a session in `window.localStorage`: shared by every tab of the origin and kept across browser
|
|
32
|
+
* restarts. The browser default.
|
|
33
|
+
*
|
|
34
|
+
* The global is read on each call, not at construction, so building one during a server render is
|
|
35
|
+
* safe; only using it there fails.
|
|
36
|
+
*/
|
|
37
|
+
export declare class LocalStorage implements AndcoStorage {
|
|
34
38
|
getItem(key: string): string | null;
|
|
35
39
|
setItem(key: string, value: string): void;
|
|
36
40
|
removeItem(key: string): void;
|
|
37
41
|
}
|
|
38
42
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* Refresh tokens rotate, so two concurrent refreshes destroy a session: the first rotates the token
|
|
42
|
-
* and every other one fails against a token that no longer exists. In-process single-flight covers
|
|
43
|
-
* one runtime; this covers several, which is what a serverless deployment and multiple browser tabs
|
|
44
|
-
* need. Not offering it is why both example backends invented their own locking, differently.
|
|
43
|
+
* Keeps a session in `window.sessionStorage`: one tab only, gone when the tab closes. A new tab starts
|
|
44
|
+
* signed out.
|
|
45
45
|
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* const lock: AndcoLock = {
|
|
49
|
-
* acquire: (key, operation) => redlock.using([`andco:${key}`], 5000, operation),
|
|
50
|
-
* };
|
|
51
|
-
* ```
|
|
46
|
+
* The global is read on each call, not at construction, so building one during a server render is
|
|
47
|
+
* safe; only using it there fails.
|
|
52
48
|
*/
|
|
53
|
-
export
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
export declare class InProcessLock implements AndcoLock {
|
|
58
|
-
#private;
|
|
59
|
-
acquire<T>(key: string, operation: () => Promise<T>): Promise<T>;
|
|
49
|
+
export declare class SessionStorage implements AndcoStorage {
|
|
50
|
+
getItem(key: string): string | null;
|
|
51
|
+
setItem(key: string, value: string): void;
|
|
52
|
+
removeItem(key: string): void;
|
|
60
53
|
}
|
|
61
54
|
//# sourceMappingURL=storage.d.ts.map
|
package/dist/storage.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"storage.d.ts","sourceRoot":"","sources":["../src/storage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,WAAW,YAAY;IAC3B,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC7D,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1D,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC/C;AAED,2FAA2F;AAC3F,qBAAa,aAAc,YAAW,YAAY;;IAGzC,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI;IAInC,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI;IAIzC,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;CAGrC;AAED
|
|
1
|
+
{"version":3,"file":"storage.d.ts","sourceRoot":"","sources":["../src/storage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,WAAW,YAAY;IAC3B,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC7D,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1D,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC/C;AAED,2FAA2F;AAC3F,qBAAa,aAAc,YAAW,YAAY;;IAGzC,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI;IAInC,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI;IAIzC,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;CAGrC;AAED;;;;;;GAMG;AACH,qBAAa,YAAa,YAAW,YAAY;IACxC,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI;IAInC,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI;IAIzC,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;CAGrC;AAED;;;;;;GAMG;AACH,qBAAa,cAAe,YAAW,YAAY;IAC1C,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI;IAInC,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI;IAIzC,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;CAGrC"}
|
package/dist/storage.js
CHANGED
|
@@ -11,37 +11,39 @@ export class MemoryStorage {
|
|
|
11
11
|
this.#values.delete(key);
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
|
-
/**
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
14
|
+
/**
|
|
15
|
+
* Keeps a session in `window.localStorage`: shared by every tab of the origin and kept across browser
|
|
16
|
+
* restarts. The browser default.
|
|
17
|
+
*
|
|
18
|
+
* The global is read on each call, not at construction, so building one during a server render is
|
|
19
|
+
* safe; only using it there fails.
|
|
20
|
+
*/
|
|
21
|
+
export class LocalStorage {
|
|
20
22
|
getItem(key) {
|
|
21
|
-
return
|
|
23
|
+
return window.localStorage.getItem(key);
|
|
22
24
|
}
|
|
23
25
|
setItem(key, value) {
|
|
24
|
-
|
|
26
|
+
window.localStorage.setItem(key, value);
|
|
25
27
|
}
|
|
26
28
|
removeItem(key) {
|
|
27
|
-
|
|
29
|
+
window.localStorage.removeItem(key);
|
|
28
30
|
}
|
|
29
31
|
}
|
|
30
|
-
/**
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Keeps a session in `window.sessionStorage`: one tab only, gone when the tab closes. A new tab starts
|
|
34
|
+
* signed out.
|
|
35
|
+
*
|
|
36
|
+
* The global is read on each call, not at construction, so building one during a server render is
|
|
37
|
+
* safe; only using it there fails.
|
|
38
|
+
*/
|
|
39
|
+
export class SessionStorage {
|
|
40
|
+
getItem(key) {
|
|
41
|
+
return window.sessionStorage.getItem(key);
|
|
42
|
+
}
|
|
43
|
+
setItem(key, value) {
|
|
44
|
+
window.sessionStorage.setItem(key, value);
|
|
45
|
+
}
|
|
46
|
+
removeItem(key) {
|
|
47
|
+
window.sessionStorage.removeItem(key);
|
|
46
48
|
}
|
|
47
49
|
}
|
package/dist/tokens.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { AndcoConfig } from "./config.js";
|
|
2
|
+
import { Result } from "./errors.js";
|
|
3
|
+
import type { AndcoGlobals } from "./globals.js";
|
|
4
|
+
/** Registered claims of an Andco access token, plus whatever else the Authorization Server signed. */
|
|
5
|
+
export type AndcoTokenClaims = {
|
|
6
|
+
readonly iss: string;
|
|
7
|
+
readonly sub?: string;
|
|
8
|
+
readonly aud?: string | readonly string[];
|
|
9
|
+
readonly exp: number;
|
|
10
|
+
readonly iat?: number;
|
|
11
|
+
readonly scope?: string;
|
|
12
|
+
readonly client_id?: string;
|
|
13
|
+
} & Readonly<Record<string, unknown>>;
|
|
14
|
+
/** Constraints beyond signature, `exp` and `iss`, which are always checked. */
|
|
15
|
+
export type AndcoTokenVerifyOptions = {
|
|
16
|
+
/** Resource Indicator the token must be addressed to. Omit to accept any audience. */
|
|
17
|
+
audience?: string;
|
|
18
|
+
/** Accepted clock drift in seconds. Defaults to 30. */
|
|
19
|
+
clockToleranceSeconds?: number;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Offline verification of Andco access tokens with the Authorization Server's JWKS.
|
|
23
|
+
*
|
|
24
|
+
* The JWKS URL comes from the baked Server Description, re-rooted on the configured issuer, and
|
|
25
|
+
* keys are cached by `kid`. An unknown `kid` triggers one (rate limited) refetch, which is how a key
|
|
26
|
+
* rotation is picked up without a restart. A valid signature proves who issued the token, not that
|
|
27
|
+
* the Grant behind it is still alive: use `grants.get` for that.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```ts
|
|
31
|
+
* const { data: claims, error } = await andco.tokens.verify(bearer, { audience: "https://api.casa-norte.example" });
|
|
32
|
+
* if (error) return new Response(null, { status: 401 });
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export declare class AndcoTokens {
|
|
36
|
+
#private;
|
|
37
|
+
constructor(options: {
|
|
38
|
+
config: AndcoConfig;
|
|
39
|
+
globals: AndcoGlobals;
|
|
40
|
+
});
|
|
41
|
+
/** The `iss` the Authorization Server signs: the configured base URL without a trailing slash. */
|
|
42
|
+
get issuer(): string;
|
|
43
|
+
/** Checks signature, `exp`, `iss` and, when given, `aud`. Never throws. */
|
|
44
|
+
verify(token: string, options?: AndcoTokenVerifyOptions): Promise<Result<AndcoTokenClaims>>;
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=tokens.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tokens.d.ts","sourceRoot":"","sources":["../src/tokens.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC/C,OAAO,EAAc,MAAM,EAAE,MAAM,aAAa,CAAC;AACjD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAGjD,sGAAsG;AACtG,MAAM,MAAM,gBAAgB,GAAG;IAC7B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,SAAS,MAAM,EAAE,CAAC;IAC1C,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;AAEtC,+EAA+E;AAC/E,MAAM,MAAM,uBAAuB,GAAG;IACpC,sFAAsF;IACtF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,uDAAuD;IACvD,qBAAqB,CAAC,EAAE,MAAM,CAAC;CAChC,CAAC;AAmBF;;;;;;;;;;;;;GAaG;AACH,qBAAa,WAAW;;gBAOH,OAAO,EAAE;QAAE,MAAM,EAAE,WAAW,CAAC;QAAC,OAAO,EAAE,YAAY,CAAA;KAAE;IAK1E,kGAAkG;IAClG,IAAW,MAAM,IAAI,MAAM,CAG1B;IAED,2EAA2E;IAC9D,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,GAAE,uBAA4B,GAAG,OAAO,CAAC,MAAM,CAAC,gBAAgB,CAAC,CAAC;CAgF7G"}
|