@o3co/auth-provider-foundation 0.12.1 → 0.13.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/README.md CHANGED
@@ -40,6 +40,7 @@ class HttpUserRepository implements UserRepository {
40
40
  constructor(options: {
41
41
  authenticateUrl: string; // POST endpoint for username/password auth
42
42
  authenticateByTokenUrl: string; // POST endpoint for token-based auth
43
+ linkFederatedIdentityUrl?: string; // POST endpoint that links a federated identity (#482)
43
44
  timeout: number; // request timeout in milliseconds
44
45
  maxResponseBytes?: number; // response body cap, default 1 MiB
45
46
  });
@@ -49,6 +50,10 @@ class HttpUserRepository implements UserRepository {
49
50
 
50
51
  // POST authenticateByTokenUrl with body: { token }
51
52
  authenticateByToken(token: string): Promise<User | null>;
53
+
54
+ // POST linkFederatedIdentityUrl with body: { userId, provider, sub, token, claims }
55
+ // — present only when the URL is configured (#482)
56
+ linkFederatedIdentity?(userId: string, identity: FederatedIdentityLink): Promise<LinkFederatedIdentityResult>;
52
57
  }
53
58
  ```
54
59
 
@@ -57,13 +62,21 @@ class HttpUserRepository implements UserRepository {
57
62
  - Throws when the upstream returns 2xx with a body that is not a JSON `User`
58
63
  (`{ id: string, username: string, … }`) — an upstream failure, not a
59
64
  "user not found".
65
+ - `linkFederatedIdentity` (#482): a `2xx` `User` is `{ ok: true, user }`;
66
+ `401` / `403` is `{ ok: false, reason: "refused" }`; `409` is
67
+ `{ ok: false, reason: "conflict" }`; anything else throws. The method is
68
+ absent when `linkFederatedIdentityUrl` is not configured, which is how the
69
+ federation routes know to refuse `?link=1` up front. What a Store checks
70
+ before answering `2xx` — never on an unverified or relay address, never by
71
+ e-mail alone — is in the
72
+ [session package README](../session/README.md#account-linking-across-federations-482).
60
73
 
61
74
  ### Constructor validation
62
75
 
63
76
  Every option is validated in the **constructor**, so a misconfigured deployment
64
77
  fails at boot rather than at the first login attempt.
65
78
 
66
- **Both URLs must use `https://`.** They carry plaintext user credentials — a
79
+ **Every URL must use `https://`** (the link endpoint included). They carry plaintext user credentials — a
67
80
  password on `authenticateUrl`, a token on `authenticateByTokenUrl` — so an
68
81
  `http://` URL does not merely weaken the connection, it publishes the credential
69
82
  to every hop on the path.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","sourceRoot":"","sources":["../src/index.mts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AA6B/E,eAAO,MAAM,uBAAuB,GAAI,WAAW;IAClD,WAAW,EAAE,cAAc,CAAC,cAAc,CAAC,CAAC;CAC5C,KAAG,IAkBH,CAAC;AAEF,OAAO,EACN,0BAA0B,EAC1B,kBAAkB,GAClB,MAAM,uCAAuC,CAAC"}
1
+ {"version":3,"file":"index.d.mts","sourceRoot":"","sources":["../src/index.mts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AA6B/E,eAAO,MAAM,uBAAuB,GAAI,WAAW;IAClD,WAAW,EAAE,cAAc,CAAC,cAAc,CAAC,CAAC;CAC5C,KAAG,IAsBH,CAAC;AAEF,OAAO,EACN,0BAA0B,EAC1B,kBAAkB,GAClB,MAAM,uCAAuC,CAAC"}
package/dist/index.mjs CHANGED
@@ -52,6 +52,10 @@ export const registerBuiltinAdapters = (factories) => {
52
52
  return new HttpUserRepository({
53
53
  authenticateUrl: config.authenticateUrl,
54
54
  authenticateByTokenUrl: config.authenticateByTokenUrl,
55
+ // #482: optional. Present → the repository can link a federated identity.
56
+ ...(typeof config.linkFederatedIdentityUrl === "string"
57
+ ? { linkFederatedIdentityUrl: config.linkFederatedIdentityUrl }
58
+ : {}),
55
59
  timeout: toNumber(config.timeout, DEFAULT_TIMEOUT_MS),
56
60
  maxResponseBytes: toNumber(config.maxResponseBytes, DEFAULT_MAX_RESPONSE_BYTES),
57
61
  });
@@ -1,4 +1,4 @@
1
- import type { User, UserRepository } from "@o3co/auth-provider-core";
1
+ import type { FederatedIdentityLink, LinkFederatedIdentityResult, User, UserRepository } from "@o3co/auth-provider-core";
2
2
  /**
3
3
  * Default ceiling on an upstream response body, in bytes.
4
4
  *
@@ -22,14 +22,30 @@ export declare class HttpUserRepository implements UserRepository {
22
22
  private authenticateByTokenUrl;
23
23
  private timeout;
24
24
  private maxResponseBytes;
25
- constructor({ authenticateUrl, authenticateByTokenUrl, timeout, maxResponseBytes, }: {
25
+ private linkFederatedIdentityUrl?;
26
+ /**
27
+ * #482 — see {@link UserRepository.linkFederatedIdentity}. Present only when
28
+ * `linkFederatedIdentityUrl` is configured, which is how the federation routes
29
+ * know to refuse `?link=1` up front instead of at the callback.
30
+ */
31
+ readonly linkFederatedIdentity?: (userId: string, identity: FederatedIdentityLink) => Promise<LinkFederatedIdentityResult>;
32
+ constructor({ authenticateUrl, authenticateByTokenUrl, linkFederatedIdentityUrl, timeout, maxResponseBytes, }: {
26
33
  authenticateUrl: string;
27
34
  authenticateByTokenUrl: string;
35
+ /** #482: optional; the Store's link endpoint. Same https rule as the other two. */
36
+ linkFederatedIdentityUrl?: string;
28
37
  timeout: number;
29
38
  maxResponseBytes?: number;
30
39
  });
31
40
  authenticate(username: string, password: string): Promise<User | null>;
32
41
  authenticateByToken(token: string): Promise<User | null>;
42
+ /**
43
+ * POST `{ userId, provider, sub, token, claims }` to the Store's link endpoint
44
+ * (#482). A `2xx` `User` is the linked account; `401` / `403` is the Store's
45
+ * refusal (policy — an unverified or relay address, one identity per
46
+ * provider, …); `409` says the identity is already someone else's.
47
+ */
48
+ private linkViaHttp;
33
49
  private post;
34
50
  }
35
51
  //# sourceMappingURL=HttpUserRepository.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"HttpUserRepository.d.mts","sourceRoot":"","sources":["../../src/repositories/HttpUserRepository.mts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,IAAI,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAGrE;;;;;;GAMG;AACH,eAAO,MAAM,0BAA0B,QAAc,CAAC;AAkItD;;;;;;;;;GASG;AACH,qBAAa,kBAAmB,YAAW,cAAc;IACxD,OAAO,CAAC,eAAe,CAAS;IAChC,OAAO,CAAC,sBAAsB,CAAS;IACvC,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,gBAAgB,CAAS;gBAErB,EACX,eAAe,EACf,sBAAsB,EACtB,OAAO,EACP,gBAA6C,GAC7C,EAAE;QACF,eAAe,EAAE,MAAM,CAAC;QACxB,sBAAsB,EAAE,MAAM,CAAC;QAC/B,OAAO,EAAE,MAAM,CAAC;QAChB,gBAAgB,CAAC,EAAE,MAAM,CAAC;KAC1B;IAqBK,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,GAAG,IAAI,CAAC;IAItE,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,GAAG,IAAI,CAAC;YAIhD,IAAI;CAqElB"}
1
+ {"version":3,"file":"HttpUserRepository.d.mts","sourceRoot":"","sources":["../../src/repositories/HttpUserRepository.mts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EACX,qBAAqB,EACrB,2BAA2B,EAC3B,IAAI,EACJ,cAAc,EACd,MAAM,0BAA0B,CAAC;AAMlC;;;;;;GAMG;AACH,eAAO,MAAM,0BAA0B,QAAc,CAAC;AAkItD;;;;;;;;;GASG;AACH,qBAAa,kBAAmB,YAAW,cAAc;IACxD,OAAO,CAAC,eAAe,CAAS;IAChC,OAAO,CAAC,sBAAsB,CAAS;IACvC,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,gBAAgB,CAAS;IACjC,OAAO,CAAC,wBAAwB,CAAC,CAAS;IAC1C;;;;OAIG;IACH,QAAQ,CAAC,qBAAqB,CAAC,EAAE,CAChC,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,qBAAqB,KAC3B,OAAO,CAAC,2BAA2B,CAAC,CAAC;gBAE9B,EACX,eAAe,EACf,sBAAsB,EACtB,wBAAwB,EACxB,OAAO,EACP,gBAA6C,GAC7C,EAAE;QACF,eAAe,EAAE,MAAM,CAAC;QACxB,sBAAsB,EAAE,MAAM,CAAC;QAC/B,mFAAmF;QACnF,wBAAwB,CAAC,EAAE,MAAM,CAAC;QAClC,OAAO,EAAE,MAAM,CAAC;QAChB,gBAAgB,CAAC,EAAE,MAAM,CAAC;KAC1B;IA4BK,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,GAAG,IAAI,CAAC;IAKtE,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,GAAG,IAAI,CAAC;IAI9D;;;;;OAKG;YACW,WAAW;YAWX,IAAI;CA4ElB"}
@@ -14,6 +14,8 @@
14
14
  * limitations under the License.
15
15
  */
16
16
  import { assertSecureEndpoint } from "../endpointUrl.mjs";
17
+ /** The Store answered 409 to a link request: the identity is already someone else's (#482). */
18
+ const CONFLICT = Symbol("conflict");
17
19
  /**
18
20
  * Default ceiling on an upstream response body, in bytes.
19
21
  *
@@ -152,9 +154,20 @@ export class HttpUserRepository {
152
154
  authenticateByTokenUrl;
153
155
  timeout;
154
156
  maxResponseBytes;
155
- constructor({ authenticateUrl, authenticateByTokenUrl, timeout, maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES, }) {
157
+ linkFederatedIdentityUrl;
158
+ /**
159
+ * #482 — see {@link UserRepository.linkFederatedIdentity}. Present only when
160
+ * `linkFederatedIdentityUrl` is configured, which is how the federation routes
161
+ * know to refuse `?link=1` up front instead of at the callback.
162
+ */
163
+ linkFederatedIdentity;
164
+ constructor({ authenticateUrl, authenticateByTokenUrl, linkFederatedIdentityUrl, timeout, maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES, }) {
156
165
  this.authenticateUrl = assertSecureEndpoint(authenticateUrl, "authenticateUrl");
157
166
  this.authenticateByTokenUrl = assertSecureEndpoint(authenticateByTokenUrl, "authenticateByTokenUrl");
167
+ if (linkFederatedIdentityUrl !== undefined) {
168
+ this.linkFederatedIdentityUrl = assertSecureEndpoint(linkFederatedIdentityUrl, "linkFederatedIdentityUrl");
169
+ this.linkFederatedIdentity = (userId, identity) => this.linkViaHttp(userId, identity);
170
+ }
158
171
  if (!isPositiveIntegerWithin(timeout, MAX_TIMEOUT_MS)) {
159
172
  throw new Error(`HttpUserRepository: "timeout" must be a positive integer no greater than ` +
160
173
  `${MAX_TIMEOUT_MS} milliseconds`);
@@ -166,12 +179,28 @@ export class HttpUserRepository {
166
179
  this.maxResponseBytes = maxResponseBytes;
167
180
  }
168
181
  async authenticate(username, password) {
182
+ // Without `acceptConflict` the sentinel is never produced — a 409 throws.
169
183
  return this.post(this.authenticateUrl, { email: username, password });
170
184
  }
171
185
  async authenticateByToken(token) {
172
186
  return this.post(this.authenticateByTokenUrl, { token });
173
187
  }
174
- async post(url, body) {
188
+ /**
189
+ * POST `{ userId, provider, sub, token, claims }` to the Store's link endpoint
190
+ * (#482). A `2xx` `User` is the linked account; `401` / `403` is the Store's
191
+ * refusal (policy — an unverified or relay address, one identity per
192
+ * provider, …); `409` says the identity is already someone else's.
193
+ */
194
+ async linkViaHttp(userId, identity) {
195
+ const url = this.linkFederatedIdentityUrl;
196
+ const answer = await this.post(url, { userId, ...identity }, { acceptConflict: true });
197
+ if (answer === CONFLICT)
198
+ return { ok: false, reason: "conflict" };
199
+ if (answer === null)
200
+ return { ok: false, reason: "refused" };
201
+ return { ok: true, user: answer };
202
+ }
203
+ async post(url, body, options = {}) {
175
204
  const controller = new AbortController();
176
205
  let timedOut = false;
177
206
  // One absolute deadline for the whole exchange, expressed twice: as the
@@ -218,6 +247,9 @@ export class HttpUserRepository {
218
247
  return parsed;
219
248
  }
220
249
  discardBody(res);
250
+ if (options.acceptConflict && res.status === 409) {
251
+ return CONFLICT;
252
+ }
221
253
  if (res.status === 401 || res.status === 403) {
222
254
  return null;
223
255
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@o3co/auth-provider-foundation",
3
3
  "description": "Production HTTP user-authentication adapter for auth.provider",
4
- "version": "0.12.1",
4
+ "version": "0.13.0",
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
7
7
  "node": ">=22.0.0"
@@ -32,15 +32,15 @@
32
32
  }
33
33
  },
34
34
  "peerDependencies": {
35
- "@o3co/auth-provider-core": "^0.12.1"
35
+ "@o3co/auth-provider-core": "^0.13.0"
36
36
  },
37
37
  "devDependencies": {
38
- "@types/node": "^26.2.0",
38
+ "@types/node": "^26.5.0",
39
39
  "msw": "^2.15.0",
40
40
  "pino": "^10.3.1",
41
- "vitest": "^4.1.11",
42
- "@vitest/coverage-v8": "^4.1.10",
43
- "@o3co/auth-provider-core": "0.12.1"
41
+ "vitest": "^5.0.0",
42
+ "@vitest/coverage-v8": "^5.0.0",
43
+ "@o3co/auth-provider-core": "0.13.0"
44
44
  },
45
45
  "scripts": {
46
46
  "prebuild": "rimraf dist",