@andco/sdk 0.0.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.
Files changed (63) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +176 -0
  3. package/dist/auth.d.ts +71 -0
  4. package/dist/auth.d.ts.map +1 -0
  5. package/dist/auth.js +98 -0
  6. package/dist/browser/controller.d.ts +76 -0
  7. package/dist/browser/controller.d.ts.map +1 -0
  8. package/dist/browser/controller.js +215 -0
  9. package/dist/browser/frame.d.ts +65 -0
  10. package/dist/browser/frame.d.ts.map +1 -0
  11. package/dist/browser/frame.js +237 -0
  12. package/dist/browser/index.d.ts +52 -0
  13. package/dist/browser/index.d.ts.map +1 -0
  14. package/dist/browser/index.js +117 -0
  15. package/dist/browser/popup.d.ts +105 -0
  16. package/dist/browser/popup.d.ts.map +1 -0
  17. package/dist/browser/popup.js +177 -0
  18. package/dist/cli/index.d.ts +47 -0
  19. package/dist/cli/index.d.ts.map +1 -0
  20. package/dist/cli/index.js +72 -0
  21. package/dist/cli/server.d.ts +11 -0
  22. package/dist/cli/server.d.ts.map +1 -0
  23. package/dist/cli/server.js +91 -0
  24. package/dist/client.d.ts +135 -0
  25. package/dist/client.d.ts.map +1 -0
  26. package/dist/client.js +210 -0
  27. package/dist/config.d.ts +82 -0
  28. package/dist/config.d.ts.map +1 -0
  29. package/dist/config.js +109 -0
  30. package/dist/credentials.d.ts +76 -0
  31. package/dist/credentials.d.ts.map +1 -0
  32. package/dist/credentials.js +0 -0
  33. package/dist/errors.d.ts +165 -0
  34. package/dist/errors.d.ts.map +1 -0
  35. package/dist/errors.js +197 -0
  36. package/dist/index.d.ts +13 -0
  37. package/dist/index.d.ts.map +1 -0
  38. package/dist/index.js +11 -0
  39. package/dist/intents.d.ts +89 -0
  40. package/dist/intents.d.ts.map +1 -0
  41. package/dist/intents.js +157 -0
  42. package/dist/oauth.d.ts +147 -0
  43. package/dist/oauth.d.ts.map +1 -0
  44. package/dist/oauth.js +348 -0
  45. package/dist/presenter.d.ts +36 -0
  46. package/dist/presenter.d.ts.map +1 -0
  47. package/dist/presenter.js +1 -0
  48. package/dist/rest.d.ts +58 -0
  49. package/dist/rest.d.ts.map +1 -0
  50. package/dist/rest.js +48 -0
  51. package/dist/server/index.d.ts +21 -0
  52. package/dist/server/index.d.ts.map +1 -0
  53. package/dist/server/index.js +27 -0
  54. package/dist/server-metadata.generated.d.ts +4 -0
  55. package/dist/server-metadata.generated.d.ts.map +1 -0
  56. package/dist/server-metadata.generated.js +49 -0
  57. package/dist/session-store.d.ts +83 -0
  58. package/dist/session-store.d.ts.map +1 -0
  59. package/dist/session-store.js +186 -0
  60. package/dist/storage.d.ts +61 -0
  61. package/dist/storage.d.ts.map +1 -0
  62. package/dist/storage.js +47 -0
  63. package/package.json +56 -0
@@ -0,0 +1,147 @@
1
+ import * as oidc from "openid-client";
2
+ import type { AndcoConfig, AndcoScope } from "./config.js";
3
+ import type { AndcoSession, AndcoUser } from "./credentials.js";
4
+ import { Result } from "./errors.js";
5
+ /** An RFC 9396 authorization detail. Its shape is owned by the Resource Server that defines it. */
6
+ export type AndcoAuthorizationDetail = {
7
+ readonly type: string;
8
+ } & Readonly<Record<string, unknown>>;
9
+ /** One Resource Server's contribution to a single authorization request. */
10
+ export type AndcoResourceAuthorization = {
11
+ readonly resourceServerId: string;
12
+ readonly resource: string;
13
+ readonly scopes: readonly AndcoScope[];
14
+ readonly authorizationDetails?: readonly AndcoAuthorizationDetail[];
15
+ readonly orgId?: string;
16
+ };
17
+ /** How an authorization request reaches the Authorization Server. */
18
+ export type AndcoTransportMode = "auto" | "get" | "par";
19
+ /** Everything a caller may vary for one authorization request. */
20
+ export type AndcoAuthorizationOptions = {
21
+ redirectTo?: string | URL;
22
+ scopes?: readonly AndcoScope[];
23
+ /** Immutable contributions from Resource Server Definitions, composed into one request. */
24
+ authorizations?: readonly AndcoResourceAuthorization[];
25
+ resource?: string | readonly string[];
26
+ orgId?: string;
27
+ authorizationDetails?: readonly AndcoAuthorizationDetail[];
28
+ /** Opaque destination reference scoped to this OAuth Client. */
29
+ externalId?: string;
30
+ /** Correlates the callback with an already-open presentation. */
31
+ state?: string;
32
+ };
33
+ /**
34
+ * A short-lived authorization transaction. The verifier must stay private until the exchange, so a
35
+ * caller persists this wherever the callback can recover it: a server session, a store entry, or a
36
+ * terminal's memory.
37
+ */
38
+ export type AndcoAuthorizationRequest = {
39
+ authorizationUrl: URL;
40
+ codeVerifier: string;
41
+ state: string;
42
+ redirectTo: string;
43
+ createdAt: number;
44
+ };
45
+ /** RFC 8628 instructions a constrained client displays instead of opening a browser. */
46
+ export type AndcoDeviceAuthorization = {
47
+ deviceCode: string;
48
+ userCode: string;
49
+ verificationUri: string;
50
+ verificationUriComplete: string;
51
+ expiresIn: number;
52
+ interval: number;
53
+ /** The server response, kept so polling needs no reconstruction by the caller. */
54
+ raw: oidc.DeviceAuthorizationResponse;
55
+ };
56
+ export type AndcoOAuthOptions = {
57
+ config: AndcoConfig;
58
+ fetch: typeof globalThis.fetch;
59
+ /** Present for a confidential client, absent for a public one. */
60
+ clientSecret?: string | null;
61
+ transport?: AndcoTransportMode;
62
+ };
63
+ /**
64
+ * The one OAuth implementation in the SDK.
65
+ *
66
+ * It replaces four parallel ones: the hand-rolled browser client, the separate confidential client,
67
+ * and the flows written by hand inside the Better Auth and Passport packages. Those diverged, which
68
+ * is exactly the failure a protocol implementation must not have. The work is delegated to
69
+ * `openid-client`, which carries no Node built-ins and therefore runs in browsers, Expo, and Tauri.
70
+ *
71
+ * Nothing here holds a session. A request is created, handed to whatever presents it, and exchanged
72
+ * with the transaction the caller kept.
73
+ *
74
+ * @example
75
+ * ```ts
76
+ * const { data: request } = await andco.oauth.createAuthorizationRequest({ scopes: ["email"] });
77
+ * const callbackUrl = await presentSomehow(request.authorizationUrl);
78
+ * const { data: session } = await andco.oauth.exchangeCallback({ callbackUrl, request });
79
+ * ```
80
+ */
81
+ export declare class AndcoOAuth {
82
+ #private;
83
+ constructor(options: AndcoOAuthOptions);
84
+ /** Whether this client authenticates itself with a secret. */
85
+ get isConfidential(): boolean;
86
+ /**
87
+ * Prepares one Authorization Code transaction with S256 PKCE, and resolves the Authorization
88
+ * Transport Policy internally so a caller never chooses between a direct request and PAR.
89
+ */
90
+ createAuthorizationRequest(options?: AndcoAuthorizationOptions): Promise<Result<AndcoAuthorizationRequest>>;
91
+ /**
92
+ * Validates a complete callback URL against the transaction that produced it, then exchanges the
93
+ * code. Validation happens here rather than in each presenter so every runtime — browser, Host
94
+ * bridge, terminal paste — gets the same checks.
95
+ */
96
+ exchangeCallback(options: {
97
+ callbackUrl: string | URL;
98
+ request: AndcoAuthorizationRequest;
99
+ }): Promise<Result<AndcoSession>>;
100
+ /**
101
+ * Resolves an authorization request someone else already assembled.
102
+ *
103
+ * Exists for integrations that generate their own OAuth request — Better Auth and Passport both
104
+ * do — and still need the Authorization Transport Policy applied. Without it each one reimplements
105
+ * the direct-versus-PAR decision, which is how three implementations of it came to exist.
106
+ *
107
+ * `transport` overrides the instance's own policy for this call only — Passport exposes this as a
108
+ * per-strategy default, distinct from the client-wide policy every other caller gets.
109
+ *
110
+ * @example
111
+ * ```ts
112
+ * const { data: url } = await andco.oauth.resolveAuthorizationUrl(parametersFromBetterAuth);
113
+ * ```
114
+ */
115
+ resolveAuthorizationUrl(parameters: URLSearchParams, transport?: AndcoTransportMode): Promise<Result<URL>>;
116
+ /** Exchanges a refresh token. The caller decides where the rotated token is written. */
117
+ refresh(refreshToken: string): Promise<Result<AndcoSession>>;
118
+ /** Reads the authenticated subject for one access token. */
119
+ userInfo(accessToken: string): Promise<Result<AndcoUser>>;
120
+ /** Starts RFC 8628 Device Authorization for a runtime that cannot present a browser. */
121
+ createDeviceAuthorizationRequest(options?: Pick<AndcoAuthorizationOptions, "scopes" | "authorizations">): Promise<Result<AndcoDeviceAuthorization>>;
122
+ /**
123
+ * Waits for the user to approve a device authorization, then returns the session.
124
+ *
125
+ * The whole poll is one await: interval pacing, `authorization_pending`, and `slow_down` are
126
+ * handled inside. The Andco CLI currently writes that loop itself, including its own backoff and
127
+ * its own expiry arithmetic, which is a protocol detail an integrator should never have to know.
128
+ */
129
+ awaitDeviceAuthorization(device: AndcoDeviceAuthorization, options?: {
130
+ signal?: AbortSignal;
131
+ }): Promise<Result<AndcoSession>>;
132
+ /**
133
+ * The underlying `openid-client` configuration.
134
+ *
135
+ * Exposed because `openid-client/passport` takes one directly. Without it the Passport package
136
+ * would have to build a second configuration from the same endpoints, which is how two
137
+ * descriptions of one Authorization Server came to exist. Synchronous, so a Passport strategy can
138
+ * be registered without awaiting anything.
139
+ *
140
+ * @example
141
+ * ```ts
142
+ * const { token_endpoint } = andco.oauth.configuration().serverMetadata();
143
+ * ```
144
+ */
145
+ configuration(): oidc.Configuration;
146
+ }
147
+ //# sourceMappingURL=oauth.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"oauth.d.ts","sourceRoot":"","sources":["../src/oauth.ts"],"names":[],"mappings":"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"}
package/dist/oauth.js ADDED
@@ -0,0 +1,348 @@
1
+ import * as oidc from "openid-client";
2
+ import { ANDCO_ERROR_CODES, AndcoError, Result } from "./errors.js";
3
+ import { andcoServerMetadata } from "./server-metadata.generated.js";
4
+ /** Rejects a transaction whose callback arrived far too late to be genuine. */
5
+ const TRANSACTION_MAX_AGE_MS = 10 * 60 * 1_000;
6
+ /** Above this length a direct authorization request risks truncation, so PAR is used. */
7
+ const DIRECT_URL_LIMIT = 2_048;
8
+ /** Parameters whose presence alone selects a Pushed Authorization Request. */
9
+ const SENSITIVE_PARAMETERS = ["authorization_details", "login_hint"];
10
+ /**
11
+ * The one OAuth implementation in the SDK.
12
+ *
13
+ * It replaces four parallel ones: the hand-rolled browser client, the separate confidential client,
14
+ * and the flows written by hand inside the Better Auth and Passport packages. Those diverged, which
15
+ * is exactly the failure a protocol implementation must not have. The work is delegated to
16
+ * `openid-client`, which carries no Node built-ins and therefore runs in browsers, Expo, and Tauri.
17
+ *
18
+ * Nothing here holds a session. A request is created, handed to whatever presents it, and exchanged
19
+ * with the transaction the caller kept.
20
+ *
21
+ * @example
22
+ * ```ts
23
+ * const { data: request } = await andco.oauth.createAuthorizationRequest({ scopes: ["email"] });
24
+ * const callbackUrl = await presentSomehow(request.authorizationUrl);
25
+ * const { data: session } = await andco.oauth.exchangeCallback({ callbackUrl, request });
26
+ * ```
27
+ */
28
+ export class AndcoOAuth {
29
+ #config;
30
+ #fetch;
31
+ #clientSecret;
32
+ #transport;
33
+ #configuration;
34
+ constructor(options) {
35
+ this.#config = options.config;
36
+ this.#fetch = options.fetch;
37
+ this.#clientSecret = options.clientSecret ?? null;
38
+ this.#transport = options.transport ?? "auto";
39
+ }
40
+ /** Whether this client authenticates itself with a secret. */
41
+ get isConfidential() {
42
+ return this.#clientSecret !== null;
43
+ }
44
+ /**
45
+ * Prepares one Authorization Code transaction with S256 PKCE, and resolves the Authorization
46
+ * Transport Policy internally so a caller never chooses between a direct request and PAR.
47
+ */
48
+ async createAuthorizationRequest(options = {}) {
49
+ try {
50
+ const redirectTo = this.#redirectTo(options.redirectTo);
51
+ const codeVerifier = oidc.randomPKCECodeVerifier();
52
+ const codeChallenge = await oidc.calculatePKCECodeChallenge(codeVerifier);
53
+ const state = options.state ?? oidc.randomState();
54
+ const parameters = this.#authorizationParameters(options, redirectTo, state, codeChallenge);
55
+ const authorizationUrl = await this.#resolveAuthorizationUrl(parameters);
56
+ return Result.ok({ authorizationUrl, codeVerifier, state, redirectTo, createdAt: Date.now() });
57
+ }
58
+ catch (cause) {
59
+ return Result.fail(AndcoError.from(cause, "authorization_request_failed"));
60
+ }
61
+ }
62
+ /**
63
+ * Validates a complete callback URL against the transaction that produced it, then exchanges the
64
+ * code. Validation happens here rather than in each presenter so every runtime — browser, Host
65
+ * bridge, terminal paste — gets the same checks.
66
+ */
67
+ async exchangeCallback(options) {
68
+ try {
69
+ const callbackUrl = new URL(options.callbackUrl);
70
+ const { request } = options;
71
+ if (!Number.isSafeInteger(request.createdAt) || Date.now() - request.createdAt > TRANSACTION_MAX_AGE_MS) {
72
+ return Result.fail(ANDCO_ERROR_CODES.INVALID_CALLBACK, { message: "the authorization transaction expired" });
73
+ }
74
+ else if (!callbackMatchesRedirect(callbackUrl, new URL(request.redirectTo))) {
75
+ return Result.fail(ANDCO_ERROR_CODES.INVALID_CALLBACK, { message: "the callback does not match redirectTo" });
76
+ }
77
+ else if (callbackUrl.searchParams.get("state") !== request.state) {
78
+ return Result.fail(ANDCO_ERROR_CODES.INVALID_CALLBACK, { message: "the callback state does not correlate" });
79
+ }
80
+ const oauthError = callbackUrl.searchParams.get("error");
81
+ if (oauthError) {
82
+ return Result.fail(oauthError.slice(0, 256), {
83
+ message: callbackUrl.searchParams.get("error_description")?.slice(0, 2_048) ?? oauthError,
84
+ });
85
+ }
86
+ else if (!callbackUrl.searchParams.has("code")) {
87
+ return Result.fail(ANDCO_ERROR_CODES.INVALID_CALLBACK, {
88
+ message: "the callback carries no authorization code",
89
+ });
90
+ }
91
+ const tokens = await oidc.authorizationCodeGrant(this.#oidc(), callbackUrl, {
92
+ pkceCodeVerifier: request.codeVerifier,
93
+ expectedState: request.state,
94
+ });
95
+ return await this.#sessionFrom(tokens);
96
+ }
97
+ catch (cause) {
98
+ return Result.fail(AndcoError.from(cause, "authorization_exchange_failed"));
99
+ }
100
+ }
101
+ /**
102
+ * Resolves an authorization request someone else already assembled.
103
+ *
104
+ * Exists for integrations that generate their own OAuth request — Better Auth and Passport both
105
+ * do — and still need the Authorization Transport Policy applied. Without it each one reimplements
106
+ * the direct-versus-PAR decision, which is how three implementations of it came to exist.
107
+ *
108
+ * `transport` overrides the instance's own policy for this call only — Passport exposes this as a
109
+ * per-strategy default, distinct from the client-wide policy every other caller gets.
110
+ *
111
+ * @example
112
+ * ```ts
113
+ * const { data: url } = await andco.oauth.resolveAuthorizationUrl(parametersFromBetterAuth);
114
+ * ```
115
+ */
116
+ async resolveAuthorizationUrl(parameters, transport) {
117
+ try {
118
+ return Result.ok(await this.#resolveAuthorizationUrl(parameters, transport));
119
+ }
120
+ catch (cause) {
121
+ return Result.fail(AndcoError.from(cause, "authorization_request_failed"));
122
+ }
123
+ }
124
+ /** Exchanges a refresh token. The caller decides where the rotated token is written. */
125
+ async refresh(refreshToken) {
126
+ try {
127
+ const tokens = await oidc.refreshTokenGrant(this.#oidc(), refreshToken);
128
+ return await this.#sessionFrom(tokens);
129
+ }
130
+ catch (cause) {
131
+ return Result.fail(AndcoError.from(cause, "refresh_failed"));
132
+ }
133
+ }
134
+ /** Reads the authenticated subject for one access token. */
135
+ async userInfo(accessToken) {
136
+ try {
137
+ // The subject check compares the UserInfo response against the token's own `sub`, which
138
+ // exists only when the token is a JWT. An opaque token offers nothing to compare, so the
139
+ // check is skipped rather than failing a request that is otherwise valid.
140
+ const subject = decodeSubject(accessToken);
141
+ const info = await oidc.fetchUserInfo(this.#oidc(), accessToken, subject ? subject.sub : oidc.skipSubjectCheck);
142
+ return Result.ok({
143
+ id: String(info.sub),
144
+ name: info.name,
145
+ email: info.email,
146
+ avatarUrl: info.picture,
147
+ });
148
+ }
149
+ catch (cause) {
150
+ return Result.fail(AndcoError.from(cause, "userinfo_failed"));
151
+ }
152
+ }
153
+ /** Starts RFC 8628 Device Authorization for a runtime that cannot present a browser. */
154
+ async createDeviceAuthorizationRequest(options = {}) {
155
+ try {
156
+ const response = await oidc.initiateDeviceAuthorization(this.#oidc(), {
157
+ scope: this.#scopes(options).join(" "),
158
+ });
159
+ return Result.ok({
160
+ deviceCode: response.device_code,
161
+ userCode: response.user_code,
162
+ verificationUri: response.verification_uri,
163
+ verificationUriComplete: response.verification_uri_complete ?? response.verification_uri,
164
+ expiresIn: response.expires_in,
165
+ interval: response.interval ?? 5,
166
+ raw: response,
167
+ });
168
+ }
169
+ catch (cause) {
170
+ return Result.fail(AndcoError.from(cause, "device_authorization_failed"));
171
+ }
172
+ }
173
+ /**
174
+ * Waits for the user to approve a device authorization, then returns the session.
175
+ *
176
+ * The whole poll is one await: interval pacing, `authorization_pending`, and `slow_down` are
177
+ * handled inside. The Andco CLI currently writes that loop itself, including its own backoff and
178
+ * its own expiry arithmetic, which is a protocol detail an integrator should never have to know.
179
+ */
180
+ async awaitDeviceAuthorization(device, options = {}) {
181
+ try {
182
+ const tokens = await oidc.pollDeviceAuthorizationGrant(this.#oidc(), device.raw, undefined, {
183
+ signal: options.signal,
184
+ });
185
+ return await this.#sessionFrom(tokens);
186
+ }
187
+ catch (cause) {
188
+ return Result.fail(AndcoError.from(cause, "device_exchange_failed"));
189
+ }
190
+ }
191
+ /**
192
+ * The underlying `openid-client` configuration.
193
+ *
194
+ * Exposed because `openid-client/passport` takes one directly. Without it the Passport package
195
+ * would have to build a second configuration from the same endpoints, which is how two
196
+ * descriptions of one Authorization Server came to exist. Synchronous, so a Passport strategy can
197
+ * be registered without awaiting anything.
198
+ *
199
+ * @example
200
+ * ```ts
201
+ * const { token_endpoint } = andco.oauth.configuration().serverMetadata();
202
+ * ```
203
+ */
204
+ configuration() {
205
+ return this.#oidc();
206
+ }
207
+ /**
208
+ * The Authorization Server, as the Authorization Server describes itself.
209
+ *
210
+ * The description is real — it comes from the server's own discovery document — but it is read
211
+ * at build time by `scripts/refresh-server-metadata.mjs` rather than at runtime by every
212
+ * application. ADR-0018 records what hand-written guesses cost; ADR-0021 records why the reading
213
+ * moved to the build. The ceiling is drift: a server that changes an endpoint or an algorithm
214
+ * breaks clients built before the change, and only regenerating the file fixes them.
215
+ */
216
+ #oidc() {
217
+ this.#configuration ??= this.#build();
218
+ return this.#configuration;
219
+ }
220
+ #build() {
221
+ const config = this.#config;
222
+ const base = config.endpoints.auth;
223
+ // `issuer` must match the `iss` the server signs, which carries no trailing slash.
224
+ const issuer = `${base.origin}${base.pathname.replace(/\/+$/, "")}`;
225
+ const secret = this.#clientSecret;
226
+ const configuration = new oidc.Configuration(andcoServerMetadata(issuer), config.clientId, secret ?? undefined, secret ? oidc.ClientSecretBasic(secret) : oidc.None());
227
+ // `CustomFetch` widens the body to include `Uint8Array`, which every runtime's `fetch` accepts
228
+ // as a `BufferSource` even though the standard lib types do not spell it that way.
229
+ configuration[oidc.customFetch] = ((input, init) => this.#fetch(input, init));
230
+ // The Andco Authorization Server is reachable over loopback HTTP in local development.
231
+ oidc.allowInsecureRequests(configuration);
232
+ return configuration;
233
+ }
234
+ #redirectTo(override) {
235
+ const value = override ?? this.#config.redirectTo;
236
+ if (!value) {
237
+ throw new AndcoError(ANDCO_ERROR_CODES.INVALID_CONFIGURATION, {
238
+ message: "redirectTo is required, on the instance or on the request",
239
+ });
240
+ }
241
+ return new URL(value).href;
242
+ }
243
+ #scopes(options) {
244
+ const contributed = (options.authorizations ?? []).flatMap((a) => [...a.scopes]);
245
+ const requested = options.scopes ?? this.#config.initialScopes;
246
+ return [...new Set([...requested, ...contributed])];
247
+ }
248
+ #authorizationParameters(options, redirectTo, state, codeChallenge) {
249
+ const parameters = new URLSearchParams({
250
+ client_id: this.#config.clientId,
251
+ response_type: "code",
252
+ redirect_uri: redirectTo,
253
+ state,
254
+ code_challenge: codeChallenge,
255
+ code_challenge_method: "S256",
256
+ });
257
+ const scopes = this.#scopes(options);
258
+ if (scopes.length)
259
+ parameters.set("scope", scopes.join(" "));
260
+ const resources = new Set();
261
+ for (const value of toArray(options.resource))
262
+ resources.add(value);
263
+ for (const contribution of options.authorizations ?? [])
264
+ resources.add(contribution.resource);
265
+ for (const resource of resources)
266
+ parameters.append("resource", resource);
267
+ const details = [
268
+ ...(options.authorizationDetails ?? []),
269
+ ...(options.authorizations ?? []).flatMap((a) => [...(a.authorizationDetails ?? [])]),
270
+ ];
271
+ if (details.length)
272
+ parameters.set("authorization_details", JSON.stringify(details));
273
+ const orgId = options.orgId ?? (options.authorizations ?? []).find((a) => a.orgId)?.orgId;
274
+ if (orgId)
275
+ parameters.set("org_id", orgId);
276
+ if (options.externalId)
277
+ parameters.set("external_id", options.externalId);
278
+ return parameters;
279
+ }
280
+ /**
281
+ * Chooses between a direct request and a Pushed Authorization Request.
282
+ *
283
+ * `auto` pushes whenever the request carries RFC 9396 details or would otherwise be long enough
284
+ * to risk truncation, and sends a direct request otherwise. Nothing falls back silently: a failed
285
+ * push is reported rather than downgraded, because downgrading would move sensitive parameters
286
+ * into a URL the user agent logs.
287
+ */
288
+ async #resolveAuthorizationUrl(parameters, transportOverride) {
289
+ const configuration = this.#oidc();
290
+ const direct = oidc.buildAuthorizationUrl(configuration, parameters);
291
+ // A user identifier and fine-grained permission details must not travel in a URL the user
292
+ // agent records in history, sends as a Referer, or writes to a log.
293
+ const sensitive = SENSITIVE_PARAMETERS.some((name) => parameters.has(name));
294
+ const transport = transportOverride ?? this.#transport;
295
+ const mode = transport === "auto" ? (sensitive || direct.href.length > DIRECT_URL_LIMIT ? "par" : "get") : transport;
296
+ if (mode === "get")
297
+ return direct;
298
+ return await oidc.buildAuthorizationUrlWithPAR(configuration, parameters);
299
+ }
300
+ async #sessionFrom(tokens) {
301
+ if (tokens.token_type && tokens.token_type.toLowerCase() !== "bearer") {
302
+ return Result.fail(ANDCO_ERROR_CODES.INVALID_RESPONSE, { message: "unsupported token type" });
303
+ }
304
+ // The ID Token is where OpenID Connect puts the subject. Reading it out of the access token
305
+ // works only while that token happens to be a JWT, which no specification promises and an
306
+ // Authorization Server may stop doing without warning.
307
+ const subject = tokens.claims?.()?.sub ?? decodeSubject(tokens.access_token)?.sub;
308
+ if (!subject)
309
+ return Result.fail(ANDCO_ERROR_CODES.INVALID_RESPONSE, { message: "the response carries no subject" });
310
+ const session = {
311
+ accessToken: tokens.access_token,
312
+ refreshToken: tokens.refresh_token ?? null,
313
+ tokenType: tokens.token_type ?? "bearer",
314
+ expiresAt: Math.floor(Date.now() / 1000) + (tokens.expires_in ?? 0),
315
+ scopes: (tokens.scope ?? "").split(/\s+/).filter(Boolean),
316
+ user: { id: subject, name: null, email: null, avatarUrl: null },
317
+ };
318
+ const user = await this.userInfo(session.accessToken);
319
+ return Result.ok(user.data ? { ...session, user: user.data } : session);
320
+ }
321
+ }
322
+ function toArray(value) {
323
+ if (value === undefined)
324
+ return [];
325
+ return typeof value === "string" ? [value] : value;
326
+ }
327
+ /** A callback must reach the exact registered redirect, not merely a similar one. */
328
+ function callbackMatchesRedirect(actual, expected) {
329
+ if (actual.origin !== expected.origin || actual.pathname !== expected.pathname)
330
+ return false;
331
+ return [...expected.searchParams].every(([key, value]) => actual.searchParams.getAll(key).includes(value));
332
+ }
333
+ /** Reads `sub` from an access token without verifying it; the server remains the authority. */
334
+ function decodeSubject(accessToken) {
335
+ const segments = accessToken.split(".");
336
+ if (segments.length < 2 || !segments[1])
337
+ return null;
338
+ try {
339
+ const payload = JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(segments[1].replace(/-/g, "+").replace(/_/g, "/")), (character) => character.charCodeAt(0))));
340
+ if (payload && typeof payload === "object" && typeof payload.sub === "string") {
341
+ return { sub: payload.sub };
342
+ }
343
+ return null;
344
+ }
345
+ catch {
346
+ return null;
347
+ }
348
+ }
@@ -0,0 +1,36 @@
1
+ import type { Result } from "./errors.js";
2
+ /** How an authorization is put in front of the user. */
3
+ export type AndcoPresentation = "popup" | "redirect";
4
+ export type AndcoPresentOptions = {
5
+ url: URL;
6
+ presentation: AndcoPresentation;
7
+ /** Exact callback the Authorization Server will reach on success. */
8
+ returnTo: URL;
9
+ /** Exact callback reached on failure. Defaults to `returnTo`. */
10
+ errorReturnTo?: URL;
11
+ /** Correlates this presentation independently of OAuth state. */
12
+ presentationId: string;
13
+ signal?: AbortSignal;
14
+ };
15
+ /**
16
+ * The boundary between the protocol and whatever shows it to a person.
17
+ *
18
+ * A presenter receives an authorization URL and answers with the callback URL that came back, or
19
+ * `null` when the user dismissed. It owns no protocol: no PKCE, no state, no token exchange.
20
+ *
21
+ * Keeping it this narrow is what makes sign-in testable without a browser — a test supplies a
22
+ * presenter that returns a prepared callback URL and the whole flow runs in plain Node. It is also
23
+ * why a redirect presentation can answer with `null` and never resolve: the document is replaced,
24
+ * and the result arrives on the next page load instead.
25
+ *
26
+ * @example
27
+ * ```ts
28
+ * const presenter: AndcoPresenter = {
29
+ * present: async ({ url }) => Result.ok(new URL(`${redirectTo}?code=test&state=${state}`)),
30
+ * };
31
+ * ```
32
+ */
33
+ export interface AndcoPresenter {
34
+ present(options: AndcoPresentOptions): Promise<Result<URL | null>>;
35
+ }
36
+ //# sourceMappingURL=presenter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"presenter.d.ts","sourceRoot":"","sources":["../src/presenter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAE1C,wDAAwD;AACxD,MAAM,MAAM,iBAAiB,GAAG,OAAO,GAAG,UAAU,CAAC;AAErD,MAAM,MAAM,mBAAmB,GAAG;IAChC,GAAG,EAAE,GAAG,CAAC;IACT,YAAY,EAAE,iBAAiB,CAAC;IAChC,qEAAqE;IACrE,QAAQ,EAAE,GAAG,CAAC;IACd,iEAAiE;IACjE,aAAa,CAAC,EAAE,GAAG,CAAC;IACpB,iEAAiE;IACjE,cAAc,EAAE,MAAM,CAAC;IACvB,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB,CAAC;AAEF;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,WAAW,cAAc;IAC7B,OAAO,CAAC,OAAO,EAAE,mBAAmB,GAAG,OAAO,CAAC,MAAM,CAAC,GAAG,GAAG,IAAI,CAAC,CAAC,CAAC;CACpE"}
@@ -0,0 +1 @@
1
+ export {};
package/dist/rest.d.ts ADDED
@@ -0,0 +1,58 @@
1
+ import { type ResultClientFor } from "@andco/openapi-fetch";
2
+ import { type AndCoRestClient } from "@andco/protocol/transport";
3
+ import type { AndcoConfig } from "./config.js";
4
+ import type { AndcoCredentials } from "./credentials.js";
5
+ import { AndcoAPIError } from "./errors.js";
6
+ export type AndcoRestOptions = {
7
+ config: AndcoConfig;
8
+ fetch: typeof globalThis.fetch;
9
+ credentials: AndcoCredentials;
10
+ /** Resource Indicator whose token authorizes these requests. Defaults to the configured API. */
11
+ resource?: string;
12
+ };
13
+ /** One cursor page of a server-paginated list. `after` resumes from a previous `next_cursor`. */
14
+ export type AndcoPageOptions = {
15
+ after?: string;
16
+ limit?: number;
17
+ };
18
+ export type AndcoRequestOptions = {
19
+ /** Query parameters. Array values repeat the key, which is what the Andco API expects. */
20
+ query?: Record<string, string | number | boolean | readonly string[] | undefined>;
21
+ headers?: Record<string, string>;
22
+ signal?: AbortSignal;
23
+ /** Makes a write idempotent. One is generated when omitted. */
24
+ idempotencyKey?: string;
25
+ };
26
+ /** `AndcoRest.http`'s type: the generated OAuth resource client, with `AndcoAPIError` as its error. */
27
+ export type AndcoHttpClient = ResultClientFor<AndCoRestClient, AndcoAPIError>;
28
+ /**
29
+ * Typed access to the Andco resource API.
30
+ *
31
+ * `http` never throws by default — every call resolves `{ data, error }` — and gains
32
+ * `.throwOnError()` for a caller that would rather reject. The transport resolves a token per
33
+ * request through the bound credentials, so a stale token refreshes without the caller knowing.
34
+ *
35
+ * @example
36
+ * ```ts
37
+ * const { data, error } = await bound.rest.http.GET("/accounts");
38
+ * if (error) return reportUnavailable(error.code);
39
+ * ```
40
+ */
41
+ export declare class AndcoRest {
42
+ #private;
43
+ /**
44
+ * Typed OAuth resource client generated from the API's OpenAPI contract.
45
+ * @example
46
+ * ```ts
47
+ * const { data, error } = await bound.rest.http.GET("/accounts");
48
+ * if (error) return reportUnavailable(error.code); // error is `AndcoError`
49
+ * ```
50
+ */
51
+ readonly http: AndcoHttpClient;
52
+ /**
53
+ * The raw OAuth resource client generated from the API's OpenAPI contract. Use this for low-level access to the API endpoints.
54
+ */
55
+ readonly raw: AndCoRestClient;
56
+ constructor(options: AndcoRestOptions);
57
+ }
58
+ //# sourceMappingURL=rest.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rest.d.ts","sourceRoot":"","sources":["../src/rest.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,eAAe,EAAc,MAAM,sBAAsB,CAAC;AACxE,OAAO,EAAE,KAAK,eAAe,EAAyB,MAAM,2BAA2B,CAAC;AACxF,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC/C,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACzD,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAE5C,MAAM,MAAM,gBAAgB,GAAG;IAC7B,MAAM,EAAE,WAAW,CAAC;IACpB,KAAK,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;IAC/B,WAAW,EAAE,gBAAgB,CAAC;IAC9B,gGAAgG;IAChG,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF,iGAAiG;AACjG,MAAM,MAAM,gBAAgB,GAAG;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAElE,MAAM,MAAM,mBAAmB,GAAG;IAChC,0FAA0F;IAC1F,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,SAAS,MAAM,EAAE,GAAG,SAAS,CAAC,CAAC;IAClF,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,+DAA+D;IAC/D,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB,CAAC;AAEF,uGAAuG;AACvG,MAAM,MAAM,eAAe,GAAG,eAAe,CAAC,eAAe,EAAE,aAAa,CAAC,CAAC;AAE9E;;;;;;;;;;;;GAYG;AACH,qBAAa,SAAS;;IAMpB;;;;;;;OAOG;IACH,SAAgB,IAAI,EAAE,eAAe,CAAC;IAEtC;;OAEG;IACH,SAAgB,GAAG,EAAE,eAAe,CAAC;gBAElB,OAAO,EAAE,gBAAgB;CAc7C"}
package/dist/rest.js ADDED
@@ -0,0 +1,48 @@
1
+ import { withResult } from "@andco/openapi-fetch";
2
+ import { createAndCoRestClient } from "@andco/protocol/transport";
3
+ import { AndcoAPIError } from "./errors.js";
4
+ /**
5
+ * Typed access to the Andco resource API.
6
+ *
7
+ * `http` never throws by default — every call resolves `{ data, error }` — and gains
8
+ * `.throwOnError()` for a caller that would rather reject. The transport resolves a token per
9
+ * request through the bound credentials, so a stale token refreshes without the caller knowing.
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * const { data, error } = await bound.rest.http.GET("/accounts");
14
+ * if (error) return reportUnavailable(error.code);
15
+ * ```
16
+ */
17
+ export class AndcoRest {
18
+ #config;
19
+ #fetch;
20
+ #credentials;
21
+ #resource;
22
+ /**
23
+ * Typed OAuth resource client generated from the API's OpenAPI contract.
24
+ * @example
25
+ * ```ts
26
+ * const { data, error } = await bound.rest.http.GET("/accounts");
27
+ * if (error) return reportUnavailable(error.code); // error is `AndcoError`
28
+ * ```
29
+ */
30
+ http;
31
+ /**
32
+ * The raw OAuth resource client generated from the API's OpenAPI contract. Use this for low-level access to the API endpoints.
33
+ */
34
+ raw;
35
+ constructor(options) {
36
+ this.#config = options.config;
37
+ this.#fetch = options.fetch;
38
+ this.#credentials = options.credentials;
39
+ this.#resource = options.resource ?? options.config.endpoints.api.origin;
40
+ this.raw = createAndCoRestClient({
41
+ endpoint: this.#config.endpoints.api,
42
+ apiVersion: this.#config.apiVersion,
43
+ accessToken: () => this.#credentials.accessTokenFor(this.#resource),
44
+ fetch: this.#fetch,
45
+ });
46
+ this.http = withResult(this.raw, AndcoAPIError.fromOpenAPIFetch);
47
+ }
48
+ }