@poly-x/core 0.1.0-alpha.9 → 0.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/dist/index.cjs +79 -3
- package/dist/index.d.cts +59 -1
- package/dist/index.d.mts +59 -1
- package/dist/index.mjs +79 -3
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -193,7 +193,16 @@ function resolveConfig(input) {
|
|
|
193
193
|
* against poly-auth/src/api/constants/auth-responses.ts (2026-07-06). Unknown
|
|
194
194
|
* shapes become a typed UNRECOGNIZED_RESPONSE — never a guess.
|
|
195
195
|
*/
|
|
196
|
-
|
|
196
|
+
/**
|
|
197
|
+
* Terminal: the session is gone and no retry brings it back. The platform revokes every session
|
|
198
|
+
* for a user on permission change, suspension, and password reset — `SESSION_REVOKED` for the
|
|
199
|
+
* revocation itself, `SESSION_MISMATCH` when the presented token no longer matches its session.
|
|
200
|
+
*/
|
|
201
|
+
const REVOKED_CODES = /* @__PURE__ */ new Set([
|
|
202
|
+
"REFRESH_TOKEN_REUSE",
|
|
203
|
+
"SESSION_REVOKED",
|
|
204
|
+
"SESSION_MISMATCH"
|
|
205
|
+
]);
|
|
197
206
|
const EXPIRED_CODES = /* @__PURE__ */ new Set([
|
|
198
207
|
"SESSION_EXPIRED",
|
|
199
208
|
"REFRESH_TOKEN_REQUIRED",
|
|
@@ -708,7 +717,21 @@ const ENDPOINTS = {
|
|
|
708
717
|
authorize: "/v1/idp/authorize",
|
|
709
718
|
token: "/v1/idp/token",
|
|
710
719
|
refresh: "/v1/auth/refresh-token",
|
|
720
|
+
/**
|
|
721
|
+
* Ecosystem (SSO) logout. Resolves the session from the *browser's* ecosystem cookie,
|
|
722
|
+
* so it only revokes when called from a context that carries it — a top-level hop.
|
|
723
|
+
* A server-to-server call reaches it without the cookie and revokes nothing (it still
|
|
724
|
+
* answers 200), which is why BFF sign-out must use the bearer-authenticated pair below.
|
|
725
|
+
*/
|
|
711
726
|
logout: "/v1/idp/logout",
|
|
727
|
+
/**
|
|
728
|
+
* Bearer-authenticated revocation: the access token identifies the session, so these
|
|
729
|
+
* work from any context that holds one — notably the BFF, where the token is sealed in
|
|
730
|
+
* the session cookie and no browser cookie reaches poly-auth. `logout` revokes this
|
|
731
|
+
* session; `logoutAll` revokes every session for the user.
|
|
732
|
+
*/
|
|
733
|
+
authLogout: "/v1/auth/logout",
|
|
734
|
+
authLogoutAll: "/v1/auth/logout-all",
|
|
712
735
|
orgResolve: "/v1/idp/org-resolve",
|
|
713
736
|
/**
|
|
714
737
|
* Completes the admin temp-password flow: the user signed in with a temporary
|
|
@@ -818,7 +841,35 @@ function toTokenSet(accessToken, refreshToken, now) {
|
|
|
818
841
|
function asRecord(value) {
|
|
819
842
|
return value && typeof value === "object" ? value : {};
|
|
820
843
|
}
|
|
821
|
-
/**
|
|
844
|
+
/**
|
|
845
|
+
* Display-claim caps (ADR-0006). These values are user-controlled and unbounded in the
|
|
846
|
+
* platform's schema, and they ride in a sealed ~4KB cookie whose failure mode is the browser
|
|
847
|
+
* silently dropping it. Cap at the boundary rather than trusting the input.
|
|
848
|
+
*/
|
|
849
|
+
const MAX_DISPLAY_NAME = 128;
|
|
850
|
+
const MAX_AVATAR_URL = 512;
|
|
851
|
+
/** A non-blank string, trimmed and capped — otherwise undefined. The shim never guesses. */
|
|
852
|
+
function displayString(value, max) {
|
|
853
|
+
if (typeof value !== "string") return void 0;
|
|
854
|
+
const trimmed = value.trim();
|
|
855
|
+
if (trimmed.length === 0) return void 0;
|
|
856
|
+
return trimmed.slice(0, max);
|
|
857
|
+
}
|
|
858
|
+
/**
|
|
859
|
+
* The best human label the token response can give us: full name, else username, else email.
|
|
860
|
+
* Each part is checked independently so a user with only one of them never renders "undefined".
|
|
861
|
+
*/
|
|
862
|
+
function deriveDisplayName(user) {
|
|
863
|
+
return displayString([displayString(user.firstName, MAX_DISPLAY_NAME), displayString(user.lastName, MAX_DISPLAY_NAME)].filter(Boolean).join(" "), MAX_DISPLAY_NAME) ?? displayString(user.username, MAX_DISPLAY_NAME) ?? displayString(user.email, MAX_DISPLAY_NAME);
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* `POST /idp/token` → StoredSession. Claims carry identity plus the bounded display subset
|
|
867
|
+
* (ADR-0006); AUTHZ populates roles/permissions later.
|
|
868
|
+
*
|
|
869
|
+
* `organizationId` comes from the exchange's `tenantId`, never from the user document: a
|
|
870
|
+
* cross-membership login resolves a tenant that is deliberately *not* the user's home
|
|
871
|
+
* organization, and reading it off the user would silently sign them into the wrong one.
|
|
872
|
+
*/
|
|
822
873
|
function normalizeExchange(body, now) {
|
|
823
874
|
const data = asRecord(asRecord(body).data);
|
|
824
875
|
const user = asRecord(data.user);
|
|
@@ -828,7 +879,10 @@ function normalizeExchange(body, now) {
|
|
|
828
879
|
userId,
|
|
829
880
|
organizationId: data.tenantId != null ? String(data.tenantId) : void 0,
|
|
830
881
|
roles: [],
|
|
831
|
-
permissions: []
|
|
882
|
+
permissions: [],
|
|
883
|
+
displayName: deriveDisplayName(user),
|
|
884
|
+
email: displayString(user.email, MAX_DISPLAY_NAME),
|
|
885
|
+
avatarUrl: displayString(user.profilePicture, MAX_AVATAR_URL)
|
|
832
886
|
};
|
|
833
887
|
return {
|
|
834
888
|
tokens: toTokenSet(String(data.accessToken ?? ""), stringOrUndefined(data.refreshToken), now),
|
|
@@ -989,6 +1043,7 @@ var AuthClient = class {
|
|
|
989
1043
|
const response = await this.transport.request({
|
|
990
1044
|
method: "POST",
|
|
991
1045
|
url: this.config.baseUrl + ENDPOINTS.token,
|
|
1046
|
+
...params.forwardedHeaders ? { headers: params.forwardedHeaders } : {},
|
|
992
1047
|
body: {
|
|
993
1048
|
grant_type: "authorization_code",
|
|
994
1049
|
code: params.code,
|
|
@@ -1033,6 +1088,11 @@ var AuthClient = class {
|
|
|
1033
1088
|
return normalizeRefresh(response.body, current, this.clock.now());
|
|
1034
1089
|
};
|
|
1035
1090
|
}
|
|
1091
|
+
/**
|
|
1092
|
+
* Ecosystem logout — only revokes when the caller carries the browser's ecosystem
|
|
1093
|
+
* cookie (a top-level hop). From a server-to-server context it clears nothing and
|
|
1094
|
+
* still answers 200; use `revokeSession` there.
|
|
1095
|
+
*/
|
|
1036
1096
|
async logout(scope = "device") {
|
|
1037
1097
|
const response = await this.transport.request({
|
|
1038
1098
|
method: "POST",
|
|
@@ -1041,6 +1101,22 @@ var AuthClient = class {
|
|
|
1041
1101
|
});
|
|
1042
1102
|
if (response.status >= 400) throw this.toError(response);
|
|
1043
1103
|
}
|
|
1104
|
+
/**
|
|
1105
|
+
* Revoke a session using the access token as proof — the only logout that works
|
|
1106
|
+
* server-side, where no browser cookie reaches poly-auth. `device` ends this session;
|
|
1107
|
+
* `everywhere` ends every session for the user (FR-SSO-5).
|
|
1108
|
+
*
|
|
1109
|
+
* The token goes in the Authorization header and nowhere else: repeating it in the
|
|
1110
|
+
* body would only widen where it can leak.
|
|
1111
|
+
*/
|
|
1112
|
+
async revokeSession(accessToken, scope = "device") {
|
|
1113
|
+
const response = await this.transport.request({
|
|
1114
|
+
method: "POST",
|
|
1115
|
+
url: this.config.baseUrl + (scope === "everywhere" ? ENDPOINTS.authLogoutAll : ENDPOINTS.authLogout),
|
|
1116
|
+
headers: { Authorization: `Bearer ${accessToken}` }
|
|
1117
|
+
});
|
|
1118
|
+
if (response.status >= 400) throw this.toError(response);
|
|
1119
|
+
}
|
|
1044
1120
|
async resolveOrg(email) {
|
|
1045
1121
|
const response = await this.transport.request({
|
|
1046
1122
|
method: "POST",
|
package/dist/index.d.cts
CHANGED
|
@@ -203,11 +203,24 @@ interface TokenSet {
|
|
|
203
203
|
/** Epoch milliseconds when the access token expires, per the server. */
|
|
204
204
|
accessTokenExpiresAt: number;
|
|
205
205
|
}
|
|
206
|
+
/**
|
|
207
|
+
* The verified identity a session carries. Per ADR-0006 this holds identity plus a **bounded
|
|
208
|
+
* display subset** — enough for a signed-in user surface — and never bulk authorization data
|
|
209
|
+
* (module graphs, team lists). Claims are sealed into a ~4KB cookie that fails by being
|
|
210
|
+
* silently dropped, so a field earns its place by being bounded and small, not by being useful.
|
|
211
|
+
*
|
|
212
|
+
* The display fields are optional: they are derived defensively from whatever the platform
|
|
213
|
+
* returns, and their absence is normal, not an error.
|
|
214
|
+
*/
|
|
206
215
|
interface SessionClaims {
|
|
207
216
|
userId: string;
|
|
208
217
|
organizationId?: string;
|
|
209
218
|
roles: readonly string[];
|
|
210
219
|
permissions: readonly string[];
|
|
220
|
+
/** Best available human label: full name → username → email. */
|
|
221
|
+
displayName?: string;
|
|
222
|
+
email?: string;
|
|
223
|
+
avatarUrl?: string;
|
|
211
224
|
}
|
|
212
225
|
interface Session {
|
|
213
226
|
claims: SessionClaims;
|
|
@@ -390,7 +403,21 @@ declare const ENDPOINTS: {
|
|
|
390
403
|
readonly authorize: "/v1/idp/authorize";
|
|
391
404
|
readonly token: "/v1/idp/token";
|
|
392
405
|
readonly refresh: "/v1/auth/refresh-token";
|
|
406
|
+
/**
|
|
407
|
+
* Ecosystem (SSO) logout. Resolves the session from the *browser's* ecosystem cookie,
|
|
408
|
+
* so it only revokes when called from a context that carries it — a top-level hop.
|
|
409
|
+
* A server-to-server call reaches it without the cookie and revokes nothing (it still
|
|
410
|
+
* answers 200), which is why BFF sign-out must use the bearer-authenticated pair below.
|
|
411
|
+
*/
|
|
393
412
|
readonly logout: "/v1/idp/logout";
|
|
413
|
+
/**
|
|
414
|
+
* Bearer-authenticated revocation: the access token identifies the session, so these
|
|
415
|
+
* work from any context that holds one — notably the BFF, where the token is sealed in
|
|
416
|
+
* the session cookie and no browser cookie reaches poly-auth. `logout` revokes this
|
|
417
|
+
* session; `logoutAll` revokes every session for the user.
|
|
418
|
+
*/
|
|
419
|
+
readonly authLogout: "/v1/auth/logout";
|
|
420
|
+
readonly authLogoutAll: "/v1/auth/logout-all";
|
|
394
421
|
readonly orgResolve: "/v1/idp/org-resolve";
|
|
395
422
|
/**
|
|
396
423
|
* Completes the admin temp-password flow: the user signed in with a temporary
|
|
@@ -476,7 +503,14 @@ declare function parseResetOutcome(status: number, body: unknown): PasswordReset
|
|
|
476
503
|
//#endregion
|
|
477
504
|
//#region src/auth/normalize.d.ts
|
|
478
505
|
declare function decodeJwtExp(token: string): number | null;
|
|
479
|
-
/**
|
|
506
|
+
/**
|
|
507
|
+
* `POST /idp/token` → StoredSession. Claims carry identity plus the bounded display subset
|
|
508
|
+
* (ADR-0006); AUTHZ populates roles/permissions later.
|
|
509
|
+
*
|
|
510
|
+
* `organizationId` comes from the exchange's `tenantId`, never from the user document: a
|
|
511
|
+
* cross-membership login resolves a tenant that is deliberately *not* the user's home
|
|
512
|
+
* organization, and reading it off the user would silently sign them into the wrong one.
|
|
513
|
+
*/
|
|
480
514
|
declare function normalizeExchange(body: unknown, now: number): StoredSession;
|
|
481
515
|
/** `POST /auth/refresh-token` → StoredSession. No user in the response, so claims carry forward. */
|
|
482
516
|
declare function normalizeRefresh(body: unknown, current: StoredSession, now: number): StoredSession;
|
|
@@ -523,6 +557,16 @@ interface ExchangeCodeParams {
|
|
|
523
557
|
code: string;
|
|
524
558
|
codeVerifier: string;
|
|
525
559
|
redirectUri: string;
|
|
560
|
+
/**
|
|
561
|
+
* Browser context to attach to the token exchange (the call that creates the
|
|
562
|
+
* session). In a BFF deployment this request originates from Node, so without
|
|
563
|
+
* these the platform records the server's user-agent/IP ("node"/Unknown) instead
|
|
564
|
+
* of the end user's device. The BFF forwards a curated allowlist of the browser's
|
|
565
|
+
* request headers (user-agent, accept-language, the sec-ch-ua* client hints, and
|
|
566
|
+
* the observed client IP as x-forwarded-for). Optional and additive — omitting it
|
|
567
|
+
* preserves the prior behavior.
|
|
568
|
+
*/
|
|
569
|
+
forwardedHeaders?: Record<string, string>;
|
|
526
570
|
}
|
|
527
571
|
interface SetPasswordParams {
|
|
528
572
|
userId: string;
|
|
@@ -615,7 +659,21 @@ declare class AuthClient {
|
|
|
615
659
|
fetchBranding(params?: FetchBrandingParams): Promise<BrandingTokens>;
|
|
616
660
|
/** The `RefreshTokens` function the SessionEngine (F003) injects. */
|
|
617
661
|
createRefresher(): RefreshTokens;
|
|
662
|
+
/**
|
|
663
|
+
* Ecosystem logout — only revokes when the caller carries the browser's ecosystem
|
|
664
|
+
* cookie (a top-level hop). From a server-to-server context it clears nothing and
|
|
665
|
+
* still answers 200; use `revokeSession` there.
|
|
666
|
+
*/
|
|
618
667
|
logout(scope?: "device" | "everywhere"): Promise<void>;
|
|
668
|
+
/**
|
|
669
|
+
* Revoke a session using the access token as proof — the only logout that works
|
|
670
|
+
* server-side, where no browser cookie reaches poly-auth. `device` ends this session;
|
|
671
|
+
* `everywhere` ends every session for the user (FR-SSO-5).
|
|
672
|
+
*
|
|
673
|
+
* The token goes in the Authorization header and nowhere else: repeating it in the
|
|
674
|
+
* body would only widen where it can leak.
|
|
675
|
+
*/
|
|
676
|
+
revokeSession(accessToken: string, scope?: "device" | "everywhere"): Promise<void>;
|
|
619
677
|
resolveOrg(email: string): Promise<{
|
|
620
678
|
resolved: boolean;
|
|
621
679
|
organizationCode?: string;
|
package/dist/index.d.mts
CHANGED
|
@@ -203,11 +203,24 @@ interface TokenSet {
|
|
|
203
203
|
/** Epoch milliseconds when the access token expires, per the server. */
|
|
204
204
|
accessTokenExpiresAt: number;
|
|
205
205
|
}
|
|
206
|
+
/**
|
|
207
|
+
* The verified identity a session carries. Per ADR-0006 this holds identity plus a **bounded
|
|
208
|
+
* display subset** — enough for a signed-in user surface — and never bulk authorization data
|
|
209
|
+
* (module graphs, team lists). Claims are sealed into a ~4KB cookie that fails by being
|
|
210
|
+
* silently dropped, so a field earns its place by being bounded and small, not by being useful.
|
|
211
|
+
*
|
|
212
|
+
* The display fields are optional: they are derived defensively from whatever the platform
|
|
213
|
+
* returns, and their absence is normal, not an error.
|
|
214
|
+
*/
|
|
206
215
|
interface SessionClaims {
|
|
207
216
|
userId: string;
|
|
208
217
|
organizationId?: string;
|
|
209
218
|
roles: readonly string[];
|
|
210
219
|
permissions: readonly string[];
|
|
220
|
+
/** Best available human label: full name → username → email. */
|
|
221
|
+
displayName?: string;
|
|
222
|
+
email?: string;
|
|
223
|
+
avatarUrl?: string;
|
|
211
224
|
}
|
|
212
225
|
interface Session {
|
|
213
226
|
claims: SessionClaims;
|
|
@@ -390,7 +403,21 @@ declare const ENDPOINTS: {
|
|
|
390
403
|
readonly authorize: "/v1/idp/authorize";
|
|
391
404
|
readonly token: "/v1/idp/token";
|
|
392
405
|
readonly refresh: "/v1/auth/refresh-token";
|
|
406
|
+
/**
|
|
407
|
+
* Ecosystem (SSO) logout. Resolves the session from the *browser's* ecosystem cookie,
|
|
408
|
+
* so it only revokes when called from a context that carries it — a top-level hop.
|
|
409
|
+
* A server-to-server call reaches it without the cookie and revokes nothing (it still
|
|
410
|
+
* answers 200), which is why BFF sign-out must use the bearer-authenticated pair below.
|
|
411
|
+
*/
|
|
393
412
|
readonly logout: "/v1/idp/logout";
|
|
413
|
+
/**
|
|
414
|
+
* Bearer-authenticated revocation: the access token identifies the session, so these
|
|
415
|
+
* work from any context that holds one — notably the BFF, where the token is sealed in
|
|
416
|
+
* the session cookie and no browser cookie reaches poly-auth. `logout` revokes this
|
|
417
|
+
* session; `logoutAll` revokes every session for the user.
|
|
418
|
+
*/
|
|
419
|
+
readonly authLogout: "/v1/auth/logout";
|
|
420
|
+
readonly authLogoutAll: "/v1/auth/logout-all";
|
|
394
421
|
readonly orgResolve: "/v1/idp/org-resolve";
|
|
395
422
|
/**
|
|
396
423
|
* Completes the admin temp-password flow: the user signed in with a temporary
|
|
@@ -476,7 +503,14 @@ declare function parseResetOutcome(status: number, body: unknown): PasswordReset
|
|
|
476
503
|
//#endregion
|
|
477
504
|
//#region src/auth/normalize.d.ts
|
|
478
505
|
declare function decodeJwtExp(token: string): number | null;
|
|
479
|
-
/**
|
|
506
|
+
/**
|
|
507
|
+
* `POST /idp/token` → StoredSession. Claims carry identity plus the bounded display subset
|
|
508
|
+
* (ADR-0006); AUTHZ populates roles/permissions later.
|
|
509
|
+
*
|
|
510
|
+
* `organizationId` comes from the exchange's `tenantId`, never from the user document: a
|
|
511
|
+
* cross-membership login resolves a tenant that is deliberately *not* the user's home
|
|
512
|
+
* organization, and reading it off the user would silently sign them into the wrong one.
|
|
513
|
+
*/
|
|
480
514
|
declare function normalizeExchange(body: unknown, now: number): StoredSession;
|
|
481
515
|
/** `POST /auth/refresh-token` → StoredSession. No user in the response, so claims carry forward. */
|
|
482
516
|
declare function normalizeRefresh(body: unknown, current: StoredSession, now: number): StoredSession;
|
|
@@ -523,6 +557,16 @@ interface ExchangeCodeParams {
|
|
|
523
557
|
code: string;
|
|
524
558
|
codeVerifier: string;
|
|
525
559
|
redirectUri: string;
|
|
560
|
+
/**
|
|
561
|
+
* Browser context to attach to the token exchange (the call that creates the
|
|
562
|
+
* session). In a BFF deployment this request originates from Node, so without
|
|
563
|
+
* these the platform records the server's user-agent/IP ("node"/Unknown) instead
|
|
564
|
+
* of the end user's device. The BFF forwards a curated allowlist of the browser's
|
|
565
|
+
* request headers (user-agent, accept-language, the sec-ch-ua* client hints, and
|
|
566
|
+
* the observed client IP as x-forwarded-for). Optional and additive — omitting it
|
|
567
|
+
* preserves the prior behavior.
|
|
568
|
+
*/
|
|
569
|
+
forwardedHeaders?: Record<string, string>;
|
|
526
570
|
}
|
|
527
571
|
interface SetPasswordParams {
|
|
528
572
|
userId: string;
|
|
@@ -615,7 +659,21 @@ declare class AuthClient {
|
|
|
615
659
|
fetchBranding(params?: FetchBrandingParams): Promise<BrandingTokens>;
|
|
616
660
|
/** The `RefreshTokens` function the SessionEngine (F003) injects. */
|
|
617
661
|
createRefresher(): RefreshTokens;
|
|
662
|
+
/**
|
|
663
|
+
* Ecosystem logout — only revokes when the caller carries the browser's ecosystem
|
|
664
|
+
* cookie (a top-level hop). From a server-to-server context it clears nothing and
|
|
665
|
+
* still answers 200; use `revokeSession` there.
|
|
666
|
+
*/
|
|
618
667
|
logout(scope?: "device" | "everywhere"): Promise<void>;
|
|
668
|
+
/**
|
|
669
|
+
* Revoke a session using the access token as proof — the only logout that works
|
|
670
|
+
* server-side, where no browser cookie reaches poly-auth. `device` ends this session;
|
|
671
|
+
* `everywhere` ends every session for the user (FR-SSO-5).
|
|
672
|
+
*
|
|
673
|
+
* The token goes in the Authorization header and nowhere else: repeating it in the
|
|
674
|
+
* body would only widen where it can leak.
|
|
675
|
+
*/
|
|
676
|
+
revokeSession(accessToken: string, scope?: "device" | "everywhere"): Promise<void>;
|
|
619
677
|
resolveOrg(email: string): Promise<{
|
|
620
678
|
resolved: boolean;
|
|
621
679
|
organizationCode?: string;
|
package/dist/index.mjs
CHANGED
|
@@ -192,7 +192,16 @@ function resolveConfig(input) {
|
|
|
192
192
|
* against poly-auth/src/api/constants/auth-responses.ts (2026-07-06). Unknown
|
|
193
193
|
* shapes become a typed UNRECOGNIZED_RESPONSE — never a guess.
|
|
194
194
|
*/
|
|
195
|
-
|
|
195
|
+
/**
|
|
196
|
+
* Terminal: the session is gone and no retry brings it back. The platform revokes every session
|
|
197
|
+
* for a user on permission change, suspension, and password reset — `SESSION_REVOKED` for the
|
|
198
|
+
* revocation itself, `SESSION_MISMATCH` when the presented token no longer matches its session.
|
|
199
|
+
*/
|
|
200
|
+
const REVOKED_CODES = /* @__PURE__ */ new Set([
|
|
201
|
+
"REFRESH_TOKEN_REUSE",
|
|
202
|
+
"SESSION_REVOKED",
|
|
203
|
+
"SESSION_MISMATCH"
|
|
204
|
+
]);
|
|
196
205
|
const EXPIRED_CODES = /* @__PURE__ */ new Set([
|
|
197
206
|
"SESSION_EXPIRED",
|
|
198
207
|
"REFRESH_TOKEN_REQUIRED",
|
|
@@ -707,7 +716,21 @@ const ENDPOINTS = {
|
|
|
707
716
|
authorize: "/v1/idp/authorize",
|
|
708
717
|
token: "/v1/idp/token",
|
|
709
718
|
refresh: "/v1/auth/refresh-token",
|
|
719
|
+
/**
|
|
720
|
+
* Ecosystem (SSO) logout. Resolves the session from the *browser's* ecosystem cookie,
|
|
721
|
+
* so it only revokes when called from a context that carries it — a top-level hop.
|
|
722
|
+
* A server-to-server call reaches it without the cookie and revokes nothing (it still
|
|
723
|
+
* answers 200), which is why BFF sign-out must use the bearer-authenticated pair below.
|
|
724
|
+
*/
|
|
710
725
|
logout: "/v1/idp/logout",
|
|
726
|
+
/**
|
|
727
|
+
* Bearer-authenticated revocation: the access token identifies the session, so these
|
|
728
|
+
* work from any context that holds one — notably the BFF, where the token is sealed in
|
|
729
|
+
* the session cookie and no browser cookie reaches poly-auth. `logout` revokes this
|
|
730
|
+
* session; `logoutAll` revokes every session for the user.
|
|
731
|
+
*/
|
|
732
|
+
authLogout: "/v1/auth/logout",
|
|
733
|
+
authLogoutAll: "/v1/auth/logout-all",
|
|
711
734
|
orgResolve: "/v1/idp/org-resolve",
|
|
712
735
|
/**
|
|
713
736
|
* Completes the admin temp-password flow: the user signed in with a temporary
|
|
@@ -817,7 +840,35 @@ function toTokenSet(accessToken, refreshToken, now) {
|
|
|
817
840
|
function asRecord(value) {
|
|
818
841
|
return value && typeof value === "object" ? value : {};
|
|
819
842
|
}
|
|
820
|
-
/**
|
|
843
|
+
/**
|
|
844
|
+
* Display-claim caps (ADR-0006). These values are user-controlled and unbounded in the
|
|
845
|
+
* platform's schema, and they ride in a sealed ~4KB cookie whose failure mode is the browser
|
|
846
|
+
* silently dropping it. Cap at the boundary rather than trusting the input.
|
|
847
|
+
*/
|
|
848
|
+
const MAX_DISPLAY_NAME = 128;
|
|
849
|
+
const MAX_AVATAR_URL = 512;
|
|
850
|
+
/** A non-blank string, trimmed and capped — otherwise undefined. The shim never guesses. */
|
|
851
|
+
function displayString(value, max) {
|
|
852
|
+
if (typeof value !== "string") return void 0;
|
|
853
|
+
const trimmed = value.trim();
|
|
854
|
+
if (trimmed.length === 0) return void 0;
|
|
855
|
+
return trimmed.slice(0, max);
|
|
856
|
+
}
|
|
857
|
+
/**
|
|
858
|
+
* The best human label the token response can give us: full name, else username, else email.
|
|
859
|
+
* Each part is checked independently so a user with only one of them never renders "undefined".
|
|
860
|
+
*/
|
|
861
|
+
function deriveDisplayName(user) {
|
|
862
|
+
return displayString([displayString(user.firstName, MAX_DISPLAY_NAME), displayString(user.lastName, MAX_DISPLAY_NAME)].filter(Boolean).join(" "), MAX_DISPLAY_NAME) ?? displayString(user.username, MAX_DISPLAY_NAME) ?? displayString(user.email, MAX_DISPLAY_NAME);
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* `POST /idp/token` → StoredSession. Claims carry identity plus the bounded display subset
|
|
866
|
+
* (ADR-0006); AUTHZ populates roles/permissions later.
|
|
867
|
+
*
|
|
868
|
+
* `organizationId` comes from the exchange's `tenantId`, never from the user document: a
|
|
869
|
+
* cross-membership login resolves a tenant that is deliberately *not* the user's home
|
|
870
|
+
* organization, and reading it off the user would silently sign them into the wrong one.
|
|
871
|
+
*/
|
|
821
872
|
function normalizeExchange(body, now) {
|
|
822
873
|
const data = asRecord(asRecord(body).data);
|
|
823
874
|
const user = asRecord(data.user);
|
|
@@ -827,7 +878,10 @@ function normalizeExchange(body, now) {
|
|
|
827
878
|
userId,
|
|
828
879
|
organizationId: data.tenantId != null ? String(data.tenantId) : void 0,
|
|
829
880
|
roles: [],
|
|
830
|
-
permissions: []
|
|
881
|
+
permissions: [],
|
|
882
|
+
displayName: deriveDisplayName(user),
|
|
883
|
+
email: displayString(user.email, MAX_DISPLAY_NAME),
|
|
884
|
+
avatarUrl: displayString(user.profilePicture, MAX_AVATAR_URL)
|
|
831
885
|
};
|
|
832
886
|
return {
|
|
833
887
|
tokens: toTokenSet(String(data.accessToken ?? ""), stringOrUndefined(data.refreshToken), now),
|
|
@@ -988,6 +1042,7 @@ var AuthClient = class {
|
|
|
988
1042
|
const response = await this.transport.request({
|
|
989
1043
|
method: "POST",
|
|
990
1044
|
url: this.config.baseUrl + ENDPOINTS.token,
|
|
1045
|
+
...params.forwardedHeaders ? { headers: params.forwardedHeaders } : {},
|
|
991
1046
|
body: {
|
|
992
1047
|
grant_type: "authorization_code",
|
|
993
1048
|
code: params.code,
|
|
@@ -1032,6 +1087,11 @@ var AuthClient = class {
|
|
|
1032
1087
|
return normalizeRefresh(response.body, current, this.clock.now());
|
|
1033
1088
|
};
|
|
1034
1089
|
}
|
|
1090
|
+
/**
|
|
1091
|
+
* Ecosystem logout — only revokes when the caller carries the browser's ecosystem
|
|
1092
|
+
* cookie (a top-level hop). From a server-to-server context it clears nothing and
|
|
1093
|
+
* still answers 200; use `revokeSession` there.
|
|
1094
|
+
*/
|
|
1035
1095
|
async logout(scope = "device") {
|
|
1036
1096
|
const response = await this.transport.request({
|
|
1037
1097
|
method: "POST",
|
|
@@ -1040,6 +1100,22 @@ var AuthClient = class {
|
|
|
1040
1100
|
});
|
|
1041
1101
|
if (response.status >= 400) throw this.toError(response);
|
|
1042
1102
|
}
|
|
1103
|
+
/**
|
|
1104
|
+
* Revoke a session using the access token as proof — the only logout that works
|
|
1105
|
+
* server-side, where no browser cookie reaches poly-auth. `device` ends this session;
|
|
1106
|
+
* `everywhere` ends every session for the user (FR-SSO-5).
|
|
1107
|
+
*
|
|
1108
|
+
* The token goes in the Authorization header and nowhere else: repeating it in the
|
|
1109
|
+
* body would only widen where it can leak.
|
|
1110
|
+
*/
|
|
1111
|
+
async revokeSession(accessToken, scope = "device") {
|
|
1112
|
+
const response = await this.transport.request({
|
|
1113
|
+
method: "POST",
|
|
1114
|
+
url: this.config.baseUrl + (scope === "everywhere" ? ENDPOINTS.authLogoutAll : ENDPOINTS.authLogout),
|
|
1115
|
+
headers: { Authorization: `Bearer ${accessToken}` }
|
|
1116
|
+
});
|
|
1117
|
+
if (response.status >= 400) throw this.toError(response);
|
|
1118
|
+
}
|
|
1043
1119
|
async resolveOrg(email) {
|
|
1044
1120
|
const response = await this.transport.request({
|
|
1045
1121
|
method: "POST",
|