@poly-x/core 0.1.0-alpha.0 → 0.1.0-alpha.10
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 +255 -8
- package/dist/index.d.cts +192 -2
- package/dist/index.d.mts +192 -2
- package/dist/index.mjs +254 -9
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -167,11 +167,21 @@ function resolveConfig(input) {
|
|
|
167
167
|
}
|
|
168
168
|
baseUrl = normalizeBaseUrl(input.baseUrl);
|
|
169
169
|
} else baseUrl = `https://${normalizeBaseUrl(key.host)}`;
|
|
170
|
+
let signInUrl;
|
|
171
|
+
if (input.signInUrl !== void 0) {
|
|
172
|
+
try {
|
|
173
|
+
new URL(input.signInUrl);
|
|
174
|
+
} catch {
|
|
175
|
+
throw new PolyXConfigError("The signInUrl is not a valid URL.", { code: "SIGN_IN_URL_INVALID" });
|
|
176
|
+
}
|
|
177
|
+
signInUrl = normalizeBaseUrl(input.signInUrl);
|
|
178
|
+
}
|
|
170
179
|
return {
|
|
171
180
|
key,
|
|
172
181
|
kind: key.kind,
|
|
173
182
|
env: key.env,
|
|
174
183
|
baseUrl,
|
|
184
|
+
signInUrl,
|
|
175
185
|
context
|
|
176
186
|
};
|
|
177
187
|
}
|
|
@@ -183,7 +193,16 @@ function resolveConfig(input) {
|
|
|
183
193
|
* against poly-auth/src/api/constants/auth-responses.ts (2026-07-06). Unknown
|
|
184
194
|
* shapes become a typed UNRECOGNIZED_RESPONSE — never a guess.
|
|
185
195
|
*/
|
|
186
|
-
|
|
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
|
+
]);
|
|
187
206
|
const EXPIRED_CODES = /* @__PURE__ */ new Set([
|
|
188
207
|
"SESSION_EXPIRED",
|
|
189
208
|
"REFRESH_TOKEN_REQUIRED",
|
|
@@ -698,8 +717,41 @@ const ENDPOINTS = {
|
|
|
698
717
|
authorize: "/v1/idp/authorize",
|
|
699
718
|
token: "/v1/idp/token",
|
|
700
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
|
+
*/
|
|
701
726
|
logout: "/v1/idp/logout",
|
|
702
|
-
|
|
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",
|
|
735
|
+
orgResolve: "/v1/idp/org-resolve",
|
|
736
|
+
/**
|
|
737
|
+
* Completes the admin temp-password flow: the user signed in with a temporary
|
|
738
|
+
* password, poly-auth answered `403 {userPasswordSet, userId}`, and this sets
|
|
739
|
+
* the real one. Only succeeds while the account is flagged `mustChangePassword`.
|
|
740
|
+
*/
|
|
741
|
+
setPassword: "/v1/auth/set-password",
|
|
742
|
+
/**
|
|
743
|
+
* Server-mediated self-service reset (poly-x v5 / F040). `forgotPassword` starts it
|
|
744
|
+
* — always a uniform 202, the token is emailed to the mailbox, never returned.
|
|
745
|
+
* `resetPassword` redeems that opaque token single-use and sets the new password.
|
|
746
|
+
*/
|
|
747
|
+
forgotPassword: "/v1/idp/forgot-password",
|
|
748
|
+
resetPassword: "/v1/idp/reset-password",
|
|
749
|
+
/**
|
|
750
|
+
* Public, pk_-scoped login-page branding (F037 / FR-BRND). GET with `client_id`
|
|
751
|
+
* (+ optional `organizationCode`) → the bounded `{ tokens }` set. Never 5xx's /
|
|
752
|
+
* never blocks login; the SDK falls back to its built-in defaults on any failure.
|
|
753
|
+
*/
|
|
754
|
+
branding: "/v1/idp/branding"
|
|
703
755
|
};
|
|
704
756
|
//#endregion
|
|
705
757
|
//#region src/auth/outcomes.ts
|
|
@@ -737,6 +789,33 @@ function parseAuthorizeOutcome(status, body) {
|
|
|
737
789
|
if (statusField === "login_required" || b.code === "LOGIN_REQUIRED") return { type: "login_required" };
|
|
738
790
|
return { type: "login_required" };
|
|
739
791
|
}
|
|
792
|
+
function parsePasswordOutcome(status, body) {
|
|
793
|
+
const b = asRecord$1(body);
|
|
794
|
+
const statusField = typeof b.status === "string" ? b.status : void 0;
|
|
795
|
+
if (statusField === "authorized" && typeof b.code === "string") return {
|
|
796
|
+
type: "authorized",
|
|
797
|
+
code: b.code,
|
|
798
|
+
state: typeof b.state === "string" ? b.state : "",
|
|
799
|
+
redirectUri: typeof b.redirectUri === "string" ? b.redirectUri : void 0
|
|
800
|
+
};
|
|
801
|
+
if (statusField === "select_tenant") return {
|
|
802
|
+
type: "select_tenant",
|
|
803
|
+
state: typeof b.state === "string" ? b.state : "",
|
|
804
|
+
tenants: parseTenants(b.tenants)
|
|
805
|
+
};
|
|
806
|
+
if (status === 403 && b.userPasswordSet === true && typeof b.userId === "string") return {
|
|
807
|
+
type: "password_change_required",
|
|
808
|
+
userId: b.userId,
|
|
809
|
+
ticket: typeof b.ticket === "string" ? b.ticket : void 0
|
|
810
|
+
};
|
|
811
|
+
if (status === 403) return { type: "no_access" };
|
|
812
|
+
return { type: "invalid_credentials" };
|
|
813
|
+
}
|
|
814
|
+
function parseResetOutcome(status, body) {
|
|
815
|
+
if (status === 200) return "ok";
|
|
816
|
+
if (asRecord$1(body).status === "weak_password") return "weak_password";
|
|
817
|
+
return "invalid_or_expired";
|
|
818
|
+
}
|
|
740
819
|
//#endregion
|
|
741
820
|
//#region src/auth/normalize.ts
|
|
742
821
|
const FALLBACK_TTL_MS = 5 * 6e4;
|
|
@@ -762,7 +841,35 @@ function toTokenSet(accessToken, refreshToken, now) {
|
|
|
762
841
|
function asRecord(value) {
|
|
763
842
|
return value && typeof value === "object" ? value : {};
|
|
764
843
|
}
|
|
765
|
-
/**
|
|
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
|
+
*/
|
|
766
873
|
function normalizeExchange(body, now) {
|
|
767
874
|
const data = asRecord(asRecord(body).data);
|
|
768
875
|
const user = asRecord(data.user);
|
|
@@ -772,7 +879,10 @@ function normalizeExchange(body, now) {
|
|
|
772
879
|
userId,
|
|
773
880
|
organizationId: data.tenantId != null ? String(data.tenantId) : void 0,
|
|
774
881
|
roles: [],
|
|
775
|
-
permissions: []
|
|
882
|
+
permissions: [],
|
|
883
|
+
displayName: deriveDisplayName(user),
|
|
884
|
+
email: displayString(user.email, MAX_DISPLAY_NAME),
|
|
885
|
+
avatarUrl: displayString(user.profilePicture, MAX_AVATAR_URL)
|
|
776
886
|
};
|
|
777
887
|
return {
|
|
778
888
|
tokens: toTokenSet(String(data.accessToken ?? ""), stringOrUndefined(data.refreshToken), now),
|
|
@@ -815,10 +925,9 @@ var AuthClient = class {
|
|
|
815
925
|
this.transport = options.transport;
|
|
816
926
|
this.clock = options.clock ?? new SystemClock();
|
|
817
927
|
}
|
|
818
|
-
|
|
819
|
-
const url = new URL(this.config.baseUrl + ENDPOINTS.authorize);
|
|
928
|
+
appendAuthorizeParams(url, params) {
|
|
820
929
|
url.searchParams.set("response_type", "code");
|
|
821
|
-
url.searchParams.set("
|
|
930
|
+
url.searchParams.set("client_id", this.config.key.raw);
|
|
822
931
|
url.searchParams.set("redirect_uri", params.redirectUri);
|
|
823
932
|
url.searchParams.set("state", params.state);
|
|
824
933
|
url.searchParams.set("code_challenge", params.challenge);
|
|
@@ -827,6 +936,20 @@ var AuthClient = class {
|
|
|
827
936
|
if (params.prompt) url.searchParams.set("prompt", params.prompt);
|
|
828
937
|
return url.toString();
|
|
829
938
|
}
|
|
939
|
+
/** The API `GET /v1/idp/authorize` URL — used for the silent warm-hop probe. */
|
|
940
|
+
buildAuthorizeUrl(params) {
|
|
941
|
+
return this.appendAuthorizeParams(new URL(this.config.baseUrl + ENDPOINTS.authorize), params);
|
|
942
|
+
}
|
|
943
|
+
/**
|
|
944
|
+
* The URL the interactive sign-in popup/redirect opens. Targets the HOSTED login
|
|
945
|
+
* PAGE (`config.signInUrl`) — which renders the form, POSTs the API, and redirects
|
|
946
|
+
* to `redirect_uri` with the code. Falls back to the API authorize URL when no
|
|
947
|
+
* `signInUrl` is configured (deployments where the API host serves the page).
|
|
948
|
+
*/
|
|
949
|
+
buildSignInUrl(params) {
|
|
950
|
+
if (!this.config.signInUrl) return this.buildAuthorizeUrl(params);
|
|
951
|
+
return this.appendAuthorizeParams(new URL(this.config.signInUrl), params);
|
|
952
|
+
}
|
|
830
953
|
/** Probe the warm hop (GET /idp/authorize) and return the typed outcome. */
|
|
831
954
|
async completeAuthorize(params) {
|
|
832
955
|
const response = await this.transport.request({
|
|
@@ -836,6 +959,86 @@ var AuthClient = class {
|
|
|
836
959
|
if (response.status >= 500) throw this.toError(response);
|
|
837
960
|
return parseAuthorizeOutcome(response.status, response.body);
|
|
838
961
|
}
|
|
962
|
+
/**
|
|
963
|
+
* The embedded `<SignIn/>` credential flow (D2b): POST `/idp/authorize` with
|
|
964
|
+
* email/password + PKCE. On `authorized` the caller exchanges the returned
|
|
965
|
+
* `code` (server-side, via the BFF) for a session — no redirect to a hosted
|
|
966
|
+
* login page. Creds live only for the duration of this request; the SDK never
|
|
967
|
+
* stores them. 5xx surfaces as a typed error; every other status is a typed
|
|
968
|
+
* outcome the UI branches on.
|
|
969
|
+
*/
|
|
970
|
+
async authorizeWithPassword(params) {
|
|
971
|
+
const response = await this.transport.request({
|
|
972
|
+
method: "POST",
|
|
973
|
+
url: this.config.baseUrl + ENDPOINTS.authorize,
|
|
974
|
+
body: {
|
|
975
|
+
response_type: "code",
|
|
976
|
+
client_id: this.config.key.raw,
|
|
977
|
+
redirect_uri: params.redirectUri,
|
|
978
|
+
state: params.state,
|
|
979
|
+
code_challenge: params.challenge,
|
|
980
|
+
code_challenge_method: "S256",
|
|
981
|
+
email: params.email,
|
|
982
|
+
password: params.password,
|
|
983
|
+
...params.organizationCode ? { organizationCode: params.organizationCode } : {},
|
|
984
|
+
...params.tenantId ? { tenantId: params.tenantId } : {}
|
|
985
|
+
}
|
|
986
|
+
});
|
|
987
|
+
if (response.status >= 500) throw this.toError(response);
|
|
988
|
+
return parsePasswordOutcome(response.status, response.body);
|
|
989
|
+
}
|
|
990
|
+
/**
|
|
991
|
+
* Set the real password for a user the platform has flagged `mustChangePassword`
|
|
992
|
+
* (the admin temp-password flow). Call this only after `authorizeWithPassword`
|
|
993
|
+
* has returned `password_change_required` for that same user — that response IS
|
|
994
|
+
* the proof the caller knows the temporary password.
|
|
995
|
+
*/
|
|
996
|
+
async setPassword(params) {
|
|
997
|
+
const response = await this.transport.request({
|
|
998
|
+
method: "PATCH",
|
|
999
|
+
url: this.config.baseUrl + ENDPOINTS.setPassword,
|
|
1000
|
+
body: {
|
|
1001
|
+
userId: params.userId,
|
|
1002
|
+
newPassword: params.newPassword,
|
|
1003
|
+
confirmPassword: params.confirmPassword,
|
|
1004
|
+
...params.ticket ? { ticket: params.ticket } : {}
|
|
1005
|
+
}
|
|
1006
|
+
});
|
|
1007
|
+
if (response.status !== 200) throw this.toError(response);
|
|
1008
|
+
}
|
|
1009
|
+
/**
|
|
1010
|
+
* Start a server-mediated self-service reset (F040). POSTs the email + this app's
|
|
1011
|
+
* publishable key; the backend resolves the account, mints a single-use token, and
|
|
1012
|
+
* emails the link ITSELF — nothing comes back but a uniform 202. Resolves on success;
|
|
1013
|
+
* a 4xx (e.g. a misconfigured key) surfaces as a typed error for the developer.
|
|
1014
|
+
*/
|
|
1015
|
+
async requestPasswordReset(params) {
|
|
1016
|
+
const response = await this.transport.request({
|
|
1017
|
+
method: "POST",
|
|
1018
|
+
url: this.config.baseUrl + ENDPOINTS.forgotPassword,
|
|
1019
|
+
body: {
|
|
1020
|
+
email: params.email,
|
|
1021
|
+
client_id: this.config.key.raw
|
|
1022
|
+
}
|
|
1023
|
+
});
|
|
1024
|
+
if (response.status >= 400) throw this.toError(response);
|
|
1025
|
+
}
|
|
1026
|
+
/**
|
|
1027
|
+
* Redeem a reset token (the opaque value from the emailed link) single-use and set
|
|
1028
|
+
* the new password. Returns a typed outcome the UI branches on; only a 5xx throws.
|
|
1029
|
+
*/
|
|
1030
|
+
async resetPassword(params) {
|
|
1031
|
+
const response = await this.transport.request({
|
|
1032
|
+
method: "POST",
|
|
1033
|
+
url: this.config.baseUrl + ENDPOINTS.resetPassword,
|
|
1034
|
+
body: {
|
|
1035
|
+
token: params.token,
|
|
1036
|
+
newPassword: params.newPassword
|
|
1037
|
+
}
|
|
1038
|
+
});
|
|
1039
|
+
if (response.status >= 500) throw this.toError(response);
|
|
1040
|
+
return parseResetOutcome(response.status, response.body);
|
|
1041
|
+
}
|
|
839
1042
|
async exchangeCode(params) {
|
|
840
1043
|
const response = await this.transport.request({
|
|
841
1044
|
method: "POST",
|
|
@@ -845,12 +1048,33 @@ var AuthClient = class {
|
|
|
845
1048
|
code: params.code,
|
|
846
1049
|
code_verifier: params.codeVerifier,
|
|
847
1050
|
redirect_uri: params.redirectUri,
|
|
848
|
-
|
|
1051
|
+
client_id: this.config.key.raw
|
|
849
1052
|
}
|
|
850
1053
|
});
|
|
851
1054
|
if (response.status !== 200) throw this.toError(response);
|
|
852
1055
|
return normalizeExchange(response.body, this.clock.now());
|
|
853
1056
|
}
|
|
1057
|
+
/**
|
|
1058
|
+
* Fetch this app's login-page branding (public, pk_-scoped). NEVER throws and never
|
|
1059
|
+
* blocks the sign-in form: any non-200, malformed body, or network failure resolves
|
|
1060
|
+
* to an empty token set so the caller keeps its built-in defaults (FR-BRND-3).
|
|
1061
|
+
*/
|
|
1062
|
+
async fetchBranding(params = {}) {
|
|
1063
|
+
try {
|
|
1064
|
+
const url = new URL(this.config.baseUrl + ENDPOINTS.branding);
|
|
1065
|
+
url.searchParams.set("client_id", this.config.key.raw);
|
|
1066
|
+
if (params.organizationCode) url.searchParams.set("organizationCode", params.organizationCode);
|
|
1067
|
+
const response = await this.transport.request({
|
|
1068
|
+
method: "GET",
|
|
1069
|
+
url: url.toString()
|
|
1070
|
+
});
|
|
1071
|
+
if (response.status !== 200) return {};
|
|
1072
|
+
const tokens = response.body?.tokens;
|
|
1073
|
+
return tokens && typeof tokens === "object" ? tokens : {};
|
|
1074
|
+
} catch {
|
|
1075
|
+
return {};
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
854
1078
|
/** The `RefreshTokens` function the SessionEngine (F003) injects. */
|
|
855
1079
|
createRefresher() {
|
|
856
1080
|
return async (current) => {
|
|
@@ -863,6 +1087,11 @@ var AuthClient = class {
|
|
|
863
1087
|
return normalizeRefresh(response.body, current, this.clock.now());
|
|
864
1088
|
};
|
|
865
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
|
+
*/
|
|
866
1095
|
async logout(scope = "device") {
|
|
867
1096
|
const response = await this.transport.request({
|
|
868
1097
|
method: "POST",
|
|
@@ -871,6 +1100,22 @@ var AuthClient = class {
|
|
|
871
1100
|
});
|
|
872
1101
|
if (response.status >= 400) throw this.toError(response);
|
|
873
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
|
+
}
|
|
874
1119
|
async resolveOrg(email) {
|
|
875
1120
|
const response = await this.transport.request({
|
|
876
1121
|
method: "POST",
|
|
@@ -932,7 +1177,9 @@ exports.mapPlatformError = mapPlatformError;
|
|
|
932
1177
|
exports.normalizeExchange = normalizeExchange;
|
|
933
1178
|
exports.normalizeRefresh = normalizeRefresh;
|
|
934
1179
|
exports.parseAuthorizeOutcome = parseAuthorizeOutcome;
|
|
1180
|
+
exports.parsePasswordOutcome = parsePasswordOutcome;
|
|
935
1181
|
exports.parsePolyXKey = parsePolyXKey;
|
|
1182
|
+
exports.parseResetOutcome = parseResetOutcome;
|
|
936
1183
|
exports.randomState = randomState;
|
|
937
1184
|
exports.redactSensitive = redactSensitive;
|
|
938
1185
|
exports.reduceSessionState = reduce;
|
package/dist/index.d.cts
CHANGED
|
@@ -27,6 +27,14 @@ interface ResolveConfigInput {
|
|
|
27
27
|
key: string;
|
|
28
28
|
/** Wins over the key-embedded host. */
|
|
29
29
|
baseUrl?: string;
|
|
30
|
+
/**
|
|
31
|
+
* URL of the HOSTED sign-in page (the account portal that renders the login
|
|
32
|
+
* form) — distinct from the API `baseUrl` the key encodes. The interactive
|
|
33
|
+
* sign-in popup/redirect targets this; the silent warm-hop probe still hits the
|
|
34
|
+
* API. When unset, sign-in falls back to the API's `/v1/idp/authorize` (for
|
|
35
|
+
* deployments where the API host also serves the login page).
|
|
36
|
+
*/
|
|
37
|
+
signInUrl?: string;
|
|
30
38
|
/** Defaults to runtime auto-detection. */
|
|
31
39
|
context?: RuntimeContext;
|
|
32
40
|
}
|
|
@@ -35,6 +43,8 @@ interface ResolvedConfig {
|
|
|
35
43
|
kind: KeyKind;
|
|
36
44
|
env: KeyEnv;
|
|
37
45
|
baseUrl: string;
|
|
46
|
+
/** Hosted sign-in page URL (see ResolveConfigInput.signInUrl); undefined = use the API. */
|
|
47
|
+
signInUrl?: string;
|
|
38
48
|
context: RuntimeContext;
|
|
39
49
|
}
|
|
40
50
|
declare function resolveConfig(input: ResolveConfigInput): ResolvedConfig;
|
|
@@ -193,11 +203,24 @@ interface TokenSet {
|
|
|
193
203
|
/** Epoch milliseconds when the access token expires, per the server. */
|
|
194
204
|
accessTokenExpiresAt: number;
|
|
195
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
|
+
*/
|
|
196
215
|
interface SessionClaims {
|
|
197
216
|
userId: string;
|
|
198
217
|
organizationId?: string;
|
|
199
218
|
roles: readonly string[];
|
|
200
219
|
permissions: readonly string[];
|
|
220
|
+
/** Best available human label: full name → username → email. */
|
|
221
|
+
displayName?: string;
|
|
222
|
+
email?: string;
|
|
223
|
+
avatarUrl?: string;
|
|
201
224
|
}
|
|
202
225
|
interface Session {
|
|
203
226
|
claims: SessionClaims;
|
|
@@ -380,8 +403,41 @@ declare const ENDPOINTS: {
|
|
|
380
403
|
readonly authorize: "/v1/idp/authorize";
|
|
381
404
|
readonly token: "/v1/idp/token";
|
|
382
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
|
+
*/
|
|
383
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";
|
|
384
421
|
readonly orgResolve: "/v1/idp/org-resolve";
|
|
422
|
+
/**
|
|
423
|
+
* Completes the admin temp-password flow: the user signed in with a temporary
|
|
424
|
+
* password, poly-auth answered `403 {userPasswordSet, userId}`, and this sets
|
|
425
|
+
* the real one. Only succeeds while the account is flagged `mustChangePassword`.
|
|
426
|
+
*/
|
|
427
|
+
readonly setPassword: "/v1/auth/set-password";
|
|
428
|
+
/**
|
|
429
|
+
* Server-mediated self-service reset (poly-x v5 / F040). `forgotPassword` starts it
|
|
430
|
+
* — always a uniform 202, the token is emailed to the mailbox, never returned.
|
|
431
|
+
* `resetPassword` redeems that opaque token single-use and sets the new password.
|
|
432
|
+
*/
|
|
433
|
+
readonly forgotPassword: "/v1/idp/forgot-password";
|
|
434
|
+
readonly resetPassword: "/v1/idp/reset-password";
|
|
435
|
+
/**
|
|
436
|
+
* Public, pk_-scoped login-page branding (F037 / FR-BRND). GET with `client_id`
|
|
437
|
+
* (+ optional `organizationCode`) → the bounded `{ tokens }` set. Never 5xx's /
|
|
438
|
+
* never blocks login; the SDK falls back to its built-in defaults on any failure.
|
|
439
|
+
*/
|
|
440
|
+
readonly branding: "/v1/idp/branding";
|
|
385
441
|
};
|
|
386
442
|
//#endregion
|
|
387
443
|
//#region src/auth/outcomes.d.ts
|
|
@@ -411,10 +467,50 @@ type AuthorizeOutcome = {
|
|
|
411
467
|
type: "login_required";
|
|
412
468
|
};
|
|
413
469
|
declare function parseAuthorizeOutcome(status: number, body: unknown): AuthorizeOutcome;
|
|
470
|
+
/**
|
|
471
|
+
* Outcome of a PASSWORD authorize (`POST /idp/authorize` with credentials) — the
|
|
472
|
+
* embedded `<SignIn/>` flow. Distinguishes invalid-credentials and the
|
|
473
|
+
* must-set-password step from the membership outcomes (the GET warm-hop parser
|
|
474
|
+
* collapses those into login_required).
|
|
475
|
+
*/
|
|
476
|
+
type PasswordAuthOutcome = {
|
|
477
|
+
type: "authorized";
|
|
478
|
+
code: string;
|
|
479
|
+
state: string;
|
|
480
|
+
redirectUri?: string;
|
|
481
|
+
} | {
|
|
482
|
+
type: "select_tenant";
|
|
483
|
+
state: string;
|
|
484
|
+
tenants: TenantOption[];
|
|
485
|
+
} | {
|
|
486
|
+
type: "invalid_credentials";
|
|
487
|
+
} | {
|
|
488
|
+
type: "password_change_required";
|
|
489
|
+
userId: string;
|
|
490
|
+
ticket?: string;
|
|
491
|
+
} | {
|
|
492
|
+
type: "no_access";
|
|
493
|
+
};
|
|
494
|
+
declare function parsePasswordOutcome(status: number, body: unknown): PasswordAuthOutcome;
|
|
495
|
+
/**
|
|
496
|
+
* The outcome of redeeming a reset token (`POST /idp/reset-password`, F040):
|
|
497
|
+
* `ok` on success, `weak_password` when it fails the platform floor, and a
|
|
498
|
+
* uniform `invalid_or_expired` for an unknown/used/expired token (the backend
|
|
499
|
+
* does not distinguish unknown from expired to the caller).
|
|
500
|
+
*/
|
|
501
|
+
type PasswordResetOutcome = "ok" | "weak_password" | "invalid_or_expired";
|
|
502
|
+
declare function parseResetOutcome(status: number, body: unknown): PasswordResetOutcome;
|
|
414
503
|
//#endregion
|
|
415
504
|
//#region src/auth/normalize.d.ts
|
|
416
505
|
declare function decodeJwtExp(token: string): number | null;
|
|
417
|
-
/**
|
|
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
|
+
*/
|
|
418
514
|
declare function normalizeExchange(body: unknown, now: number): StoredSession;
|
|
419
515
|
/** `POST /auth/refresh-token` → StoredSession. No user in the response, so claims carry forward. */
|
|
420
516
|
declare function normalizeRefresh(body: unknown, current: StoredSession, now: number): StoredSession;
|
|
@@ -462,18 +558,112 @@ interface ExchangeCodeParams {
|
|
|
462
558
|
codeVerifier: string;
|
|
463
559
|
redirectUri: string;
|
|
464
560
|
}
|
|
561
|
+
interface SetPasswordParams {
|
|
562
|
+
userId: string;
|
|
563
|
+
newPassword: string;
|
|
564
|
+
confirmPassword: string;
|
|
565
|
+
/**
|
|
566
|
+
* Single-use proof-of-possession ticket from the `password_change_required`
|
|
567
|
+
* outcome. When present the platform trusts the ticket over `userId`; when the
|
|
568
|
+
* backend enforces tickets, it's required. Omit against older backends.
|
|
569
|
+
*/
|
|
570
|
+
ticket?: string;
|
|
571
|
+
}
|
|
572
|
+
interface AuthorizeWithPasswordParams {
|
|
573
|
+
email: string;
|
|
574
|
+
password: string;
|
|
575
|
+
redirectUri: string;
|
|
576
|
+
state: string;
|
|
577
|
+
challenge: string;
|
|
578
|
+
organizationCode?: string;
|
|
579
|
+
/** Set on the re-attempt after the user picks a workspace (select_tenant). */
|
|
580
|
+
tenantId?: string;
|
|
581
|
+
}
|
|
582
|
+
/** The bounded login-page branding token set (FR-BRND-1). Every field is optional. */
|
|
583
|
+
interface BrandingTokens {
|
|
584
|
+
displayName?: string;
|
|
585
|
+
logoUrl?: string;
|
|
586
|
+
primaryColor?: string;
|
|
587
|
+
backgroundColor?: string;
|
|
588
|
+
}
|
|
589
|
+
interface FetchBrandingParams {
|
|
590
|
+
/** Refine app-level branding with an org's when the org is already known. */
|
|
591
|
+
organizationCode?: string;
|
|
592
|
+
}
|
|
465
593
|
declare class AuthClient {
|
|
466
594
|
private readonly config;
|
|
467
595
|
private readonly transport;
|
|
468
596
|
private readonly clock;
|
|
469
597
|
constructor(options: AuthClientOptions);
|
|
598
|
+
private appendAuthorizeParams;
|
|
599
|
+
/** The API `GET /v1/idp/authorize` URL — used for the silent warm-hop probe. */
|
|
470
600
|
buildAuthorizeUrl(params: BuildAuthorizeUrlParams): string;
|
|
601
|
+
/**
|
|
602
|
+
* The URL the interactive sign-in popup/redirect opens. Targets the HOSTED login
|
|
603
|
+
* PAGE (`config.signInUrl`) — which renders the form, POSTs the API, and redirects
|
|
604
|
+
* to `redirect_uri` with the code. Falls back to the API authorize URL when no
|
|
605
|
+
* `signInUrl` is configured (deployments where the API host serves the page).
|
|
606
|
+
*/
|
|
607
|
+
buildSignInUrl(params: BuildAuthorizeUrlParams): string;
|
|
471
608
|
/** Probe the warm hop (GET /idp/authorize) and return the typed outcome. */
|
|
472
609
|
completeAuthorize(params: BuildAuthorizeUrlParams): Promise<AuthorizeOutcome>;
|
|
610
|
+
/**
|
|
611
|
+
* The embedded `<SignIn/>` credential flow (D2b): POST `/idp/authorize` with
|
|
612
|
+
* email/password + PKCE. On `authorized` the caller exchanges the returned
|
|
613
|
+
* `code` (server-side, via the BFF) for a session — no redirect to a hosted
|
|
614
|
+
* login page. Creds live only for the duration of this request; the SDK never
|
|
615
|
+
* stores them. 5xx surfaces as a typed error; every other status is a typed
|
|
616
|
+
* outcome the UI branches on.
|
|
617
|
+
*/
|
|
618
|
+
authorizeWithPassword(params: AuthorizeWithPasswordParams): Promise<PasswordAuthOutcome>;
|
|
619
|
+
/**
|
|
620
|
+
* Set the real password for a user the platform has flagged `mustChangePassword`
|
|
621
|
+
* (the admin temp-password flow). Call this only after `authorizeWithPassword`
|
|
622
|
+
* has returned `password_change_required` for that same user — that response IS
|
|
623
|
+
* the proof the caller knows the temporary password.
|
|
624
|
+
*/
|
|
625
|
+
setPassword(params: SetPasswordParams): Promise<void>;
|
|
626
|
+
/**
|
|
627
|
+
* Start a server-mediated self-service reset (F040). POSTs the email + this app's
|
|
628
|
+
* publishable key; the backend resolves the account, mints a single-use token, and
|
|
629
|
+
* emails the link ITSELF — nothing comes back but a uniform 202. Resolves on success;
|
|
630
|
+
* a 4xx (e.g. a misconfigured key) surfaces as a typed error for the developer.
|
|
631
|
+
*/
|
|
632
|
+
requestPasswordReset(params: {
|
|
633
|
+
email: string;
|
|
634
|
+
}): Promise<void>;
|
|
635
|
+
/**
|
|
636
|
+
* Redeem a reset token (the opaque value from the emailed link) single-use and set
|
|
637
|
+
* the new password. Returns a typed outcome the UI branches on; only a 5xx throws.
|
|
638
|
+
*/
|
|
639
|
+
resetPassword(params: {
|
|
640
|
+
token: string;
|
|
641
|
+
newPassword: string;
|
|
642
|
+
}): Promise<PasswordResetOutcome>;
|
|
473
643
|
exchangeCode(params: ExchangeCodeParams): Promise<StoredSession>;
|
|
644
|
+
/**
|
|
645
|
+
* Fetch this app's login-page branding (public, pk_-scoped). NEVER throws and never
|
|
646
|
+
* blocks the sign-in form: any non-200, malformed body, or network failure resolves
|
|
647
|
+
* to an empty token set so the caller keeps its built-in defaults (FR-BRND-3).
|
|
648
|
+
*/
|
|
649
|
+
fetchBranding(params?: FetchBrandingParams): Promise<BrandingTokens>;
|
|
474
650
|
/** The `RefreshTokens` function the SessionEngine (F003) injects. */
|
|
475
651
|
createRefresher(): RefreshTokens;
|
|
652
|
+
/**
|
|
653
|
+
* Ecosystem logout — only revokes when the caller carries the browser's ecosystem
|
|
654
|
+
* cookie (a top-level hop). From a server-to-server context it clears nothing and
|
|
655
|
+
* still answers 200; use `revokeSession` there.
|
|
656
|
+
*/
|
|
476
657
|
logout(scope?: "device" | "everywhere"): Promise<void>;
|
|
658
|
+
/**
|
|
659
|
+
* Revoke a session using the access token as proof — the only logout that works
|
|
660
|
+
* server-side, where no browser cookie reaches poly-auth. `device` ends this session;
|
|
661
|
+
* `everywhere` ends every session for the user (FR-SSO-5).
|
|
662
|
+
*
|
|
663
|
+
* The token goes in the Authorization header and nowhere else: repeating it in the
|
|
664
|
+
* body would only widen where it can leak.
|
|
665
|
+
*/
|
|
666
|
+
revokeSession(accessToken: string, scope?: "device" | "everywhere"): Promise<void>;
|
|
477
667
|
resolveOrg(email: string): Promise<{
|
|
478
668
|
resolved: boolean;
|
|
479
669
|
organizationCode?: string;
|
|
@@ -488,4 +678,4 @@ declare class AuthClient {
|
|
|
488
678
|
declare const PACKAGE_NAME = "@poly-x/core";
|
|
489
679
|
declare const version = "0.0.0";
|
|
490
680
|
//#endregion
|
|
491
|
-
export { AuthClient, type AuthClientOptions, type AuthorizeOutcome, type BuildAuthorizeUrlParams, type Clock, DEFAULT_RETRY_POLICY, DEFAULT_TIMEOUT_MS, ENDPOINTS, type ExchangeCodeParams, FetchTransport, type FieldError, ForbiddenError, type HttpMethod, INITIAL_STATE as INITIAL_SESSION_STATE, InMemoryLock, InMemoryPkceStore, InMemorySessionChannel, InMemorySessionStore, type KeyEnv, type KeyKind, type Lock, type MockRoute, MockTransport, PACKAGE_NAME, type ParsedKey, type PkceEntry, type PkcePair, type PkceStore, type PlatformResponseLike, PolyXConfigError, PolyXError, type PolyXErrorOptions, RateLimitedError, type RefreshTokens, type ResolveConfigInput, type ResolvedConfig, type RetryPolicy, type RuntimeContext, type Session, type SessionChannel, type SessionChannelEvent, type SessionClaims, SessionEngine, type SessionEngineOptions, type SessionEvent, SessionExpiredError, SessionRevokedError, type SessionState, type SessionStore, type SignedOutReason, type StoredSession, SystemClock, type TenantOption, type TimerHandle, type TokenSet, type Transport, type TransportRequest, type TransportResponse, UnauthenticatedError, UpstreamError, ValidationError, computeChallenge, createSessionEngine, decodeJwtExp, generatePkce, isTransientStatus, mapPlatformError, normalizeExchange, normalizeRefresh, parseAuthorizeOutcome, parsePolyXKey, randomState, redactSensitive, reduce as reduceSessionState, resolveConfig, version };
|
|
681
|
+
export { AuthClient, type AuthClientOptions, type AuthorizeOutcome, type AuthorizeWithPasswordParams, type BrandingTokens, type BuildAuthorizeUrlParams, type Clock, DEFAULT_RETRY_POLICY, DEFAULT_TIMEOUT_MS, ENDPOINTS, type ExchangeCodeParams, type FetchBrandingParams, FetchTransport, type FieldError, ForbiddenError, type HttpMethod, INITIAL_STATE as INITIAL_SESSION_STATE, InMemoryLock, InMemoryPkceStore, InMemorySessionChannel, InMemorySessionStore, type KeyEnv, type KeyKind, type Lock, type MockRoute, MockTransport, PACKAGE_NAME, type ParsedKey, type PasswordAuthOutcome, type PasswordResetOutcome, type PkceEntry, type PkcePair, type PkceStore, type PlatformResponseLike, PolyXConfigError, PolyXError, type PolyXErrorOptions, RateLimitedError, type RefreshTokens, type ResolveConfigInput, type ResolvedConfig, type RetryPolicy, type RuntimeContext, type Session, type SessionChannel, type SessionChannelEvent, type SessionClaims, SessionEngine, type SessionEngineOptions, type SessionEvent, SessionExpiredError, SessionRevokedError, type SessionState, type SessionStore, type SetPasswordParams, type SignedOutReason, type StoredSession, SystemClock, type TenantOption, type TimerHandle, type TokenSet, type Transport, type TransportRequest, type TransportResponse, UnauthenticatedError, UpstreamError, ValidationError, computeChallenge, createSessionEngine, decodeJwtExp, generatePkce, isTransientStatus, mapPlatformError, normalizeExchange, normalizeRefresh, parseAuthorizeOutcome, parsePasswordOutcome, parsePolyXKey, parseResetOutcome, randomState, redactSensitive, reduce as reduceSessionState, resolveConfig, version };
|
package/dist/index.d.mts
CHANGED
|
@@ -27,6 +27,14 @@ interface ResolveConfigInput {
|
|
|
27
27
|
key: string;
|
|
28
28
|
/** Wins over the key-embedded host. */
|
|
29
29
|
baseUrl?: string;
|
|
30
|
+
/**
|
|
31
|
+
* URL of the HOSTED sign-in page (the account portal that renders the login
|
|
32
|
+
* form) — distinct from the API `baseUrl` the key encodes. The interactive
|
|
33
|
+
* sign-in popup/redirect targets this; the silent warm-hop probe still hits the
|
|
34
|
+
* API. When unset, sign-in falls back to the API's `/v1/idp/authorize` (for
|
|
35
|
+
* deployments where the API host also serves the login page).
|
|
36
|
+
*/
|
|
37
|
+
signInUrl?: string;
|
|
30
38
|
/** Defaults to runtime auto-detection. */
|
|
31
39
|
context?: RuntimeContext;
|
|
32
40
|
}
|
|
@@ -35,6 +43,8 @@ interface ResolvedConfig {
|
|
|
35
43
|
kind: KeyKind;
|
|
36
44
|
env: KeyEnv;
|
|
37
45
|
baseUrl: string;
|
|
46
|
+
/** Hosted sign-in page URL (see ResolveConfigInput.signInUrl); undefined = use the API. */
|
|
47
|
+
signInUrl?: string;
|
|
38
48
|
context: RuntimeContext;
|
|
39
49
|
}
|
|
40
50
|
declare function resolveConfig(input: ResolveConfigInput): ResolvedConfig;
|
|
@@ -193,11 +203,24 @@ interface TokenSet {
|
|
|
193
203
|
/** Epoch milliseconds when the access token expires, per the server. */
|
|
194
204
|
accessTokenExpiresAt: number;
|
|
195
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
|
+
*/
|
|
196
215
|
interface SessionClaims {
|
|
197
216
|
userId: string;
|
|
198
217
|
organizationId?: string;
|
|
199
218
|
roles: readonly string[];
|
|
200
219
|
permissions: readonly string[];
|
|
220
|
+
/** Best available human label: full name → username → email. */
|
|
221
|
+
displayName?: string;
|
|
222
|
+
email?: string;
|
|
223
|
+
avatarUrl?: string;
|
|
201
224
|
}
|
|
202
225
|
interface Session {
|
|
203
226
|
claims: SessionClaims;
|
|
@@ -380,8 +403,41 @@ declare const ENDPOINTS: {
|
|
|
380
403
|
readonly authorize: "/v1/idp/authorize";
|
|
381
404
|
readonly token: "/v1/idp/token";
|
|
382
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
|
+
*/
|
|
383
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";
|
|
384
421
|
readonly orgResolve: "/v1/idp/org-resolve";
|
|
422
|
+
/**
|
|
423
|
+
* Completes the admin temp-password flow: the user signed in with a temporary
|
|
424
|
+
* password, poly-auth answered `403 {userPasswordSet, userId}`, and this sets
|
|
425
|
+
* the real one. Only succeeds while the account is flagged `mustChangePassword`.
|
|
426
|
+
*/
|
|
427
|
+
readonly setPassword: "/v1/auth/set-password";
|
|
428
|
+
/**
|
|
429
|
+
* Server-mediated self-service reset (poly-x v5 / F040). `forgotPassword` starts it
|
|
430
|
+
* — always a uniform 202, the token is emailed to the mailbox, never returned.
|
|
431
|
+
* `resetPassword` redeems that opaque token single-use and sets the new password.
|
|
432
|
+
*/
|
|
433
|
+
readonly forgotPassword: "/v1/idp/forgot-password";
|
|
434
|
+
readonly resetPassword: "/v1/idp/reset-password";
|
|
435
|
+
/**
|
|
436
|
+
* Public, pk_-scoped login-page branding (F037 / FR-BRND). GET with `client_id`
|
|
437
|
+
* (+ optional `organizationCode`) → the bounded `{ tokens }` set. Never 5xx's /
|
|
438
|
+
* never blocks login; the SDK falls back to its built-in defaults on any failure.
|
|
439
|
+
*/
|
|
440
|
+
readonly branding: "/v1/idp/branding";
|
|
385
441
|
};
|
|
386
442
|
//#endregion
|
|
387
443
|
//#region src/auth/outcomes.d.ts
|
|
@@ -411,10 +467,50 @@ type AuthorizeOutcome = {
|
|
|
411
467
|
type: "login_required";
|
|
412
468
|
};
|
|
413
469
|
declare function parseAuthorizeOutcome(status: number, body: unknown): AuthorizeOutcome;
|
|
470
|
+
/**
|
|
471
|
+
* Outcome of a PASSWORD authorize (`POST /idp/authorize` with credentials) — the
|
|
472
|
+
* embedded `<SignIn/>` flow. Distinguishes invalid-credentials and the
|
|
473
|
+
* must-set-password step from the membership outcomes (the GET warm-hop parser
|
|
474
|
+
* collapses those into login_required).
|
|
475
|
+
*/
|
|
476
|
+
type PasswordAuthOutcome = {
|
|
477
|
+
type: "authorized";
|
|
478
|
+
code: string;
|
|
479
|
+
state: string;
|
|
480
|
+
redirectUri?: string;
|
|
481
|
+
} | {
|
|
482
|
+
type: "select_tenant";
|
|
483
|
+
state: string;
|
|
484
|
+
tenants: TenantOption[];
|
|
485
|
+
} | {
|
|
486
|
+
type: "invalid_credentials";
|
|
487
|
+
} | {
|
|
488
|
+
type: "password_change_required";
|
|
489
|
+
userId: string;
|
|
490
|
+
ticket?: string;
|
|
491
|
+
} | {
|
|
492
|
+
type: "no_access";
|
|
493
|
+
};
|
|
494
|
+
declare function parsePasswordOutcome(status: number, body: unknown): PasswordAuthOutcome;
|
|
495
|
+
/**
|
|
496
|
+
* The outcome of redeeming a reset token (`POST /idp/reset-password`, F040):
|
|
497
|
+
* `ok` on success, `weak_password` when it fails the platform floor, and a
|
|
498
|
+
* uniform `invalid_or_expired` for an unknown/used/expired token (the backend
|
|
499
|
+
* does not distinguish unknown from expired to the caller).
|
|
500
|
+
*/
|
|
501
|
+
type PasswordResetOutcome = "ok" | "weak_password" | "invalid_or_expired";
|
|
502
|
+
declare function parseResetOutcome(status: number, body: unknown): PasswordResetOutcome;
|
|
414
503
|
//#endregion
|
|
415
504
|
//#region src/auth/normalize.d.ts
|
|
416
505
|
declare function decodeJwtExp(token: string): number | null;
|
|
417
|
-
/**
|
|
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
|
+
*/
|
|
418
514
|
declare function normalizeExchange(body: unknown, now: number): StoredSession;
|
|
419
515
|
/** `POST /auth/refresh-token` → StoredSession. No user in the response, so claims carry forward. */
|
|
420
516
|
declare function normalizeRefresh(body: unknown, current: StoredSession, now: number): StoredSession;
|
|
@@ -462,18 +558,112 @@ interface ExchangeCodeParams {
|
|
|
462
558
|
codeVerifier: string;
|
|
463
559
|
redirectUri: string;
|
|
464
560
|
}
|
|
561
|
+
interface SetPasswordParams {
|
|
562
|
+
userId: string;
|
|
563
|
+
newPassword: string;
|
|
564
|
+
confirmPassword: string;
|
|
565
|
+
/**
|
|
566
|
+
* Single-use proof-of-possession ticket from the `password_change_required`
|
|
567
|
+
* outcome. When present the platform trusts the ticket over `userId`; when the
|
|
568
|
+
* backend enforces tickets, it's required. Omit against older backends.
|
|
569
|
+
*/
|
|
570
|
+
ticket?: string;
|
|
571
|
+
}
|
|
572
|
+
interface AuthorizeWithPasswordParams {
|
|
573
|
+
email: string;
|
|
574
|
+
password: string;
|
|
575
|
+
redirectUri: string;
|
|
576
|
+
state: string;
|
|
577
|
+
challenge: string;
|
|
578
|
+
organizationCode?: string;
|
|
579
|
+
/** Set on the re-attempt after the user picks a workspace (select_tenant). */
|
|
580
|
+
tenantId?: string;
|
|
581
|
+
}
|
|
582
|
+
/** The bounded login-page branding token set (FR-BRND-1). Every field is optional. */
|
|
583
|
+
interface BrandingTokens {
|
|
584
|
+
displayName?: string;
|
|
585
|
+
logoUrl?: string;
|
|
586
|
+
primaryColor?: string;
|
|
587
|
+
backgroundColor?: string;
|
|
588
|
+
}
|
|
589
|
+
interface FetchBrandingParams {
|
|
590
|
+
/** Refine app-level branding with an org's when the org is already known. */
|
|
591
|
+
organizationCode?: string;
|
|
592
|
+
}
|
|
465
593
|
declare class AuthClient {
|
|
466
594
|
private readonly config;
|
|
467
595
|
private readonly transport;
|
|
468
596
|
private readonly clock;
|
|
469
597
|
constructor(options: AuthClientOptions);
|
|
598
|
+
private appendAuthorizeParams;
|
|
599
|
+
/** The API `GET /v1/idp/authorize` URL — used for the silent warm-hop probe. */
|
|
470
600
|
buildAuthorizeUrl(params: BuildAuthorizeUrlParams): string;
|
|
601
|
+
/**
|
|
602
|
+
* The URL the interactive sign-in popup/redirect opens. Targets the HOSTED login
|
|
603
|
+
* PAGE (`config.signInUrl`) — which renders the form, POSTs the API, and redirects
|
|
604
|
+
* to `redirect_uri` with the code. Falls back to the API authorize URL when no
|
|
605
|
+
* `signInUrl` is configured (deployments where the API host serves the page).
|
|
606
|
+
*/
|
|
607
|
+
buildSignInUrl(params: BuildAuthorizeUrlParams): string;
|
|
471
608
|
/** Probe the warm hop (GET /idp/authorize) and return the typed outcome. */
|
|
472
609
|
completeAuthorize(params: BuildAuthorizeUrlParams): Promise<AuthorizeOutcome>;
|
|
610
|
+
/**
|
|
611
|
+
* The embedded `<SignIn/>` credential flow (D2b): POST `/idp/authorize` with
|
|
612
|
+
* email/password + PKCE. On `authorized` the caller exchanges the returned
|
|
613
|
+
* `code` (server-side, via the BFF) for a session — no redirect to a hosted
|
|
614
|
+
* login page. Creds live only for the duration of this request; the SDK never
|
|
615
|
+
* stores them. 5xx surfaces as a typed error; every other status is a typed
|
|
616
|
+
* outcome the UI branches on.
|
|
617
|
+
*/
|
|
618
|
+
authorizeWithPassword(params: AuthorizeWithPasswordParams): Promise<PasswordAuthOutcome>;
|
|
619
|
+
/**
|
|
620
|
+
* Set the real password for a user the platform has flagged `mustChangePassword`
|
|
621
|
+
* (the admin temp-password flow). Call this only after `authorizeWithPassword`
|
|
622
|
+
* has returned `password_change_required` for that same user — that response IS
|
|
623
|
+
* the proof the caller knows the temporary password.
|
|
624
|
+
*/
|
|
625
|
+
setPassword(params: SetPasswordParams): Promise<void>;
|
|
626
|
+
/**
|
|
627
|
+
* Start a server-mediated self-service reset (F040). POSTs the email + this app's
|
|
628
|
+
* publishable key; the backend resolves the account, mints a single-use token, and
|
|
629
|
+
* emails the link ITSELF — nothing comes back but a uniform 202. Resolves on success;
|
|
630
|
+
* a 4xx (e.g. a misconfigured key) surfaces as a typed error for the developer.
|
|
631
|
+
*/
|
|
632
|
+
requestPasswordReset(params: {
|
|
633
|
+
email: string;
|
|
634
|
+
}): Promise<void>;
|
|
635
|
+
/**
|
|
636
|
+
* Redeem a reset token (the opaque value from the emailed link) single-use and set
|
|
637
|
+
* the new password. Returns a typed outcome the UI branches on; only a 5xx throws.
|
|
638
|
+
*/
|
|
639
|
+
resetPassword(params: {
|
|
640
|
+
token: string;
|
|
641
|
+
newPassword: string;
|
|
642
|
+
}): Promise<PasswordResetOutcome>;
|
|
473
643
|
exchangeCode(params: ExchangeCodeParams): Promise<StoredSession>;
|
|
644
|
+
/**
|
|
645
|
+
* Fetch this app's login-page branding (public, pk_-scoped). NEVER throws and never
|
|
646
|
+
* blocks the sign-in form: any non-200, malformed body, or network failure resolves
|
|
647
|
+
* to an empty token set so the caller keeps its built-in defaults (FR-BRND-3).
|
|
648
|
+
*/
|
|
649
|
+
fetchBranding(params?: FetchBrandingParams): Promise<BrandingTokens>;
|
|
474
650
|
/** The `RefreshTokens` function the SessionEngine (F003) injects. */
|
|
475
651
|
createRefresher(): RefreshTokens;
|
|
652
|
+
/**
|
|
653
|
+
* Ecosystem logout — only revokes when the caller carries the browser's ecosystem
|
|
654
|
+
* cookie (a top-level hop). From a server-to-server context it clears nothing and
|
|
655
|
+
* still answers 200; use `revokeSession` there.
|
|
656
|
+
*/
|
|
476
657
|
logout(scope?: "device" | "everywhere"): Promise<void>;
|
|
658
|
+
/**
|
|
659
|
+
* Revoke a session using the access token as proof — the only logout that works
|
|
660
|
+
* server-side, where no browser cookie reaches poly-auth. `device` ends this session;
|
|
661
|
+
* `everywhere` ends every session for the user (FR-SSO-5).
|
|
662
|
+
*
|
|
663
|
+
* The token goes in the Authorization header and nowhere else: repeating it in the
|
|
664
|
+
* body would only widen where it can leak.
|
|
665
|
+
*/
|
|
666
|
+
revokeSession(accessToken: string, scope?: "device" | "everywhere"): Promise<void>;
|
|
477
667
|
resolveOrg(email: string): Promise<{
|
|
478
668
|
resolved: boolean;
|
|
479
669
|
organizationCode?: string;
|
|
@@ -488,4 +678,4 @@ declare class AuthClient {
|
|
|
488
678
|
declare const PACKAGE_NAME = "@poly-x/core";
|
|
489
679
|
declare const version = "0.0.0";
|
|
490
680
|
//#endregion
|
|
491
|
-
export { AuthClient, type AuthClientOptions, type AuthorizeOutcome, type BuildAuthorizeUrlParams, type Clock, DEFAULT_RETRY_POLICY, DEFAULT_TIMEOUT_MS, ENDPOINTS, type ExchangeCodeParams, FetchTransport, type FieldError, ForbiddenError, type HttpMethod, INITIAL_STATE as INITIAL_SESSION_STATE, InMemoryLock, InMemoryPkceStore, InMemorySessionChannel, InMemorySessionStore, type KeyEnv, type KeyKind, type Lock, type MockRoute, MockTransport, PACKAGE_NAME, type ParsedKey, type PkceEntry, type PkcePair, type PkceStore, type PlatformResponseLike, PolyXConfigError, PolyXError, type PolyXErrorOptions, RateLimitedError, type RefreshTokens, type ResolveConfigInput, type ResolvedConfig, type RetryPolicy, type RuntimeContext, type Session, type SessionChannel, type SessionChannelEvent, type SessionClaims, SessionEngine, type SessionEngineOptions, type SessionEvent, SessionExpiredError, SessionRevokedError, type SessionState, type SessionStore, type SignedOutReason, type StoredSession, SystemClock, type TenantOption, type TimerHandle, type TokenSet, type Transport, type TransportRequest, type TransportResponse, UnauthenticatedError, UpstreamError, ValidationError, computeChallenge, createSessionEngine, decodeJwtExp, generatePkce, isTransientStatus, mapPlatformError, normalizeExchange, normalizeRefresh, parseAuthorizeOutcome, parsePolyXKey, randomState, redactSensitive, reduce as reduceSessionState, resolveConfig, version };
|
|
681
|
+
export { AuthClient, type AuthClientOptions, type AuthorizeOutcome, type AuthorizeWithPasswordParams, type BrandingTokens, type BuildAuthorizeUrlParams, type Clock, DEFAULT_RETRY_POLICY, DEFAULT_TIMEOUT_MS, ENDPOINTS, type ExchangeCodeParams, type FetchBrandingParams, FetchTransport, type FieldError, ForbiddenError, type HttpMethod, INITIAL_STATE as INITIAL_SESSION_STATE, InMemoryLock, InMemoryPkceStore, InMemorySessionChannel, InMemorySessionStore, type KeyEnv, type KeyKind, type Lock, type MockRoute, MockTransport, PACKAGE_NAME, type ParsedKey, type PasswordAuthOutcome, type PasswordResetOutcome, type PkceEntry, type PkcePair, type PkceStore, type PlatformResponseLike, PolyXConfigError, PolyXError, type PolyXErrorOptions, RateLimitedError, type RefreshTokens, type ResolveConfigInput, type ResolvedConfig, type RetryPolicy, type RuntimeContext, type Session, type SessionChannel, type SessionChannelEvent, type SessionClaims, SessionEngine, type SessionEngineOptions, type SessionEvent, SessionExpiredError, SessionRevokedError, type SessionState, type SessionStore, type SetPasswordParams, type SignedOutReason, type StoredSession, SystemClock, type TenantOption, type TimerHandle, type TokenSet, type Transport, type TransportRequest, type TransportResponse, UnauthenticatedError, UpstreamError, ValidationError, computeChallenge, createSessionEngine, decodeJwtExp, generatePkce, isTransientStatus, mapPlatformError, normalizeExchange, normalizeRefresh, parseAuthorizeOutcome, parsePasswordOutcome, parsePolyXKey, parseResetOutcome, randomState, redactSensitive, reduce as reduceSessionState, resolveConfig, version };
|
package/dist/index.mjs
CHANGED
|
@@ -166,11 +166,21 @@ function resolveConfig(input) {
|
|
|
166
166
|
}
|
|
167
167
|
baseUrl = normalizeBaseUrl(input.baseUrl);
|
|
168
168
|
} else baseUrl = `https://${normalizeBaseUrl(key.host)}`;
|
|
169
|
+
let signInUrl;
|
|
170
|
+
if (input.signInUrl !== void 0) {
|
|
171
|
+
try {
|
|
172
|
+
new URL(input.signInUrl);
|
|
173
|
+
} catch {
|
|
174
|
+
throw new PolyXConfigError("The signInUrl is not a valid URL.", { code: "SIGN_IN_URL_INVALID" });
|
|
175
|
+
}
|
|
176
|
+
signInUrl = normalizeBaseUrl(input.signInUrl);
|
|
177
|
+
}
|
|
169
178
|
return {
|
|
170
179
|
key,
|
|
171
180
|
kind: key.kind,
|
|
172
181
|
env: key.env,
|
|
173
182
|
baseUrl,
|
|
183
|
+
signInUrl,
|
|
174
184
|
context
|
|
175
185
|
};
|
|
176
186
|
}
|
|
@@ -182,7 +192,16 @@ function resolveConfig(input) {
|
|
|
182
192
|
* against poly-auth/src/api/constants/auth-responses.ts (2026-07-06). Unknown
|
|
183
193
|
* shapes become a typed UNRECOGNIZED_RESPONSE — never a guess.
|
|
184
194
|
*/
|
|
185
|
-
|
|
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
|
+
]);
|
|
186
205
|
const EXPIRED_CODES = /* @__PURE__ */ new Set([
|
|
187
206
|
"SESSION_EXPIRED",
|
|
188
207
|
"REFRESH_TOKEN_REQUIRED",
|
|
@@ -697,8 +716,41 @@ const ENDPOINTS = {
|
|
|
697
716
|
authorize: "/v1/idp/authorize",
|
|
698
717
|
token: "/v1/idp/token",
|
|
699
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
|
+
*/
|
|
700
725
|
logout: "/v1/idp/logout",
|
|
701
|
-
|
|
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",
|
|
734
|
+
orgResolve: "/v1/idp/org-resolve",
|
|
735
|
+
/**
|
|
736
|
+
* Completes the admin temp-password flow: the user signed in with a temporary
|
|
737
|
+
* password, poly-auth answered `403 {userPasswordSet, userId}`, and this sets
|
|
738
|
+
* the real one. Only succeeds while the account is flagged `mustChangePassword`.
|
|
739
|
+
*/
|
|
740
|
+
setPassword: "/v1/auth/set-password",
|
|
741
|
+
/**
|
|
742
|
+
* Server-mediated self-service reset (poly-x v5 / F040). `forgotPassword` starts it
|
|
743
|
+
* — always a uniform 202, the token is emailed to the mailbox, never returned.
|
|
744
|
+
* `resetPassword` redeems that opaque token single-use and sets the new password.
|
|
745
|
+
*/
|
|
746
|
+
forgotPassword: "/v1/idp/forgot-password",
|
|
747
|
+
resetPassword: "/v1/idp/reset-password",
|
|
748
|
+
/**
|
|
749
|
+
* Public, pk_-scoped login-page branding (F037 / FR-BRND). GET with `client_id`
|
|
750
|
+
* (+ optional `organizationCode`) → the bounded `{ tokens }` set. Never 5xx's /
|
|
751
|
+
* never blocks login; the SDK falls back to its built-in defaults on any failure.
|
|
752
|
+
*/
|
|
753
|
+
branding: "/v1/idp/branding"
|
|
702
754
|
};
|
|
703
755
|
//#endregion
|
|
704
756
|
//#region src/auth/outcomes.ts
|
|
@@ -736,6 +788,33 @@ function parseAuthorizeOutcome(status, body) {
|
|
|
736
788
|
if (statusField === "login_required" || b.code === "LOGIN_REQUIRED") return { type: "login_required" };
|
|
737
789
|
return { type: "login_required" };
|
|
738
790
|
}
|
|
791
|
+
function parsePasswordOutcome(status, body) {
|
|
792
|
+
const b = asRecord$1(body);
|
|
793
|
+
const statusField = typeof b.status === "string" ? b.status : void 0;
|
|
794
|
+
if (statusField === "authorized" && typeof b.code === "string") return {
|
|
795
|
+
type: "authorized",
|
|
796
|
+
code: b.code,
|
|
797
|
+
state: typeof b.state === "string" ? b.state : "",
|
|
798
|
+
redirectUri: typeof b.redirectUri === "string" ? b.redirectUri : void 0
|
|
799
|
+
};
|
|
800
|
+
if (statusField === "select_tenant") return {
|
|
801
|
+
type: "select_tenant",
|
|
802
|
+
state: typeof b.state === "string" ? b.state : "",
|
|
803
|
+
tenants: parseTenants(b.tenants)
|
|
804
|
+
};
|
|
805
|
+
if (status === 403 && b.userPasswordSet === true && typeof b.userId === "string") return {
|
|
806
|
+
type: "password_change_required",
|
|
807
|
+
userId: b.userId,
|
|
808
|
+
ticket: typeof b.ticket === "string" ? b.ticket : void 0
|
|
809
|
+
};
|
|
810
|
+
if (status === 403) return { type: "no_access" };
|
|
811
|
+
return { type: "invalid_credentials" };
|
|
812
|
+
}
|
|
813
|
+
function parseResetOutcome(status, body) {
|
|
814
|
+
if (status === 200) return "ok";
|
|
815
|
+
if (asRecord$1(body).status === "weak_password") return "weak_password";
|
|
816
|
+
return "invalid_or_expired";
|
|
817
|
+
}
|
|
739
818
|
//#endregion
|
|
740
819
|
//#region src/auth/normalize.ts
|
|
741
820
|
const FALLBACK_TTL_MS = 5 * 6e4;
|
|
@@ -761,7 +840,35 @@ function toTokenSet(accessToken, refreshToken, now) {
|
|
|
761
840
|
function asRecord(value) {
|
|
762
841
|
return value && typeof value === "object" ? value : {};
|
|
763
842
|
}
|
|
764
|
-
/**
|
|
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
|
+
*/
|
|
765
872
|
function normalizeExchange(body, now) {
|
|
766
873
|
const data = asRecord(asRecord(body).data);
|
|
767
874
|
const user = asRecord(data.user);
|
|
@@ -771,7 +878,10 @@ function normalizeExchange(body, now) {
|
|
|
771
878
|
userId,
|
|
772
879
|
organizationId: data.tenantId != null ? String(data.tenantId) : void 0,
|
|
773
880
|
roles: [],
|
|
774
|
-
permissions: []
|
|
881
|
+
permissions: [],
|
|
882
|
+
displayName: deriveDisplayName(user),
|
|
883
|
+
email: displayString(user.email, MAX_DISPLAY_NAME),
|
|
884
|
+
avatarUrl: displayString(user.profilePicture, MAX_AVATAR_URL)
|
|
775
885
|
};
|
|
776
886
|
return {
|
|
777
887
|
tokens: toTokenSet(String(data.accessToken ?? ""), stringOrUndefined(data.refreshToken), now),
|
|
@@ -814,10 +924,9 @@ var AuthClient = class {
|
|
|
814
924
|
this.transport = options.transport;
|
|
815
925
|
this.clock = options.clock ?? new SystemClock();
|
|
816
926
|
}
|
|
817
|
-
|
|
818
|
-
const url = new URL(this.config.baseUrl + ENDPOINTS.authorize);
|
|
927
|
+
appendAuthorizeParams(url, params) {
|
|
819
928
|
url.searchParams.set("response_type", "code");
|
|
820
|
-
url.searchParams.set("
|
|
929
|
+
url.searchParams.set("client_id", this.config.key.raw);
|
|
821
930
|
url.searchParams.set("redirect_uri", params.redirectUri);
|
|
822
931
|
url.searchParams.set("state", params.state);
|
|
823
932
|
url.searchParams.set("code_challenge", params.challenge);
|
|
@@ -826,6 +935,20 @@ var AuthClient = class {
|
|
|
826
935
|
if (params.prompt) url.searchParams.set("prompt", params.prompt);
|
|
827
936
|
return url.toString();
|
|
828
937
|
}
|
|
938
|
+
/** The API `GET /v1/idp/authorize` URL — used for the silent warm-hop probe. */
|
|
939
|
+
buildAuthorizeUrl(params) {
|
|
940
|
+
return this.appendAuthorizeParams(new URL(this.config.baseUrl + ENDPOINTS.authorize), params);
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* The URL the interactive sign-in popup/redirect opens. Targets the HOSTED login
|
|
944
|
+
* PAGE (`config.signInUrl`) — which renders the form, POSTs the API, and redirects
|
|
945
|
+
* to `redirect_uri` with the code. Falls back to the API authorize URL when no
|
|
946
|
+
* `signInUrl` is configured (deployments where the API host serves the page).
|
|
947
|
+
*/
|
|
948
|
+
buildSignInUrl(params) {
|
|
949
|
+
if (!this.config.signInUrl) return this.buildAuthorizeUrl(params);
|
|
950
|
+
return this.appendAuthorizeParams(new URL(this.config.signInUrl), params);
|
|
951
|
+
}
|
|
829
952
|
/** Probe the warm hop (GET /idp/authorize) and return the typed outcome. */
|
|
830
953
|
async completeAuthorize(params) {
|
|
831
954
|
const response = await this.transport.request({
|
|
@@ -835,6 +958,86 @@ var AuthClient = class {
|
|
|
835
958
|
if (response.status >= 500) throw this.toError(response);
|
|
836
959
|
return parseAuthorizeOutcome(response.status, response.body);
|
|
837
960
|
}
|
|
961
|
+
/**
|
|
962
|
+
* The embedded `<SignIn/>` credential flow (D2b): POST `/idp/authorize` with
|
|
963
|
+
* email/password + PKCE. On `authorized` the caller exchanges the returned
|
|
964
|
+
* `code` (server-side, via the BFF) for a session — no redirect to a hosted
|
|
965
|
+
* login page. Creds live only for the duration of this request; the SDK never
|
|
966
|
+
* stores them. 5xx surfaces as a typed error; every other status is a typed
|
|
967
|
+
* outcome the UI branches on.
|
|
968
|
+
*/
|
|
969
|
+
async authorizeWithPassword(params) {
|
|
970
|
+
const response = await this.transport.request({
|
|
971
|
+
method: "POST",
|
|
972
|
+
url: this.config.baseUrl + ENDPOINTS.authorize,
|
|
973
|
+
body: {
|
|
974
|
+
response_type: "code",
|
|
975
|
+
client_id: this.config.key.raw,
|
|
976
|
+
redirect_uri: params.redirectUri,
|
|
977
|
+
state: params.state,
|
|
978
|
+
code_challenge: params.challenge,
|
|
979
|
+
code_challenge_method: "S256",
|
|
980
|
+
email: params.email,
|
|
981
|
+
password: params.password,
|
|
982
|
+
...params.organizationCode ? { organizationCode: params.organizationCode } : {},
|
|
983
|
+
...params.tenantId ? { tenantId: params.tenantId } : {}
|
|
984
|
+
}
|
|
985
|
+
});
|
|
986
|
+
if (response.status >= 500) throw this.toError(response);
|
|
987
|
+
return parsePasswordOutcome(response.status, response.body);
|
|
988
|
+
}
|
|
989
|
+
/**
|
|
990
|
+
* Set the real password for a user the platform has flagged `mustChangePassword`
|
|
991
|
+
* (the admin temp-password flow). Call this only after `authorizeWithPassword`
|
|
992
|
+
* has returned `password_change_required` for that same user — that response IS
|
|
993
|
+
* the proof the caller knows the temporary password.
|
|
994
|
+
*/
|
|
995
|
+
async setPassword(params) {
|
|
996
|
+
const response = await this.transport.request({
|
|
997
|
+
method: "PATCH",
|
|
998
|
+
url: this.config.baseUrl + ENDPOINTS.setPassword,
|
|
999
|
+
body: {
|
|
1000
|
+
userId: params.userId,
|
|
1001
|
+
newPassword: params.newPassword,
|
|
1002
|
+
confirmPassword: params.confirmPassword,
|
|
1003
|
+
...params.ticket ? { ticket: params.ticket } : {}
|
|
1004
|
+
}
|
|
1005
|
+
});
|
|
1006
|
+
if (response.status !== 200) throw this.toError(response);
|
|
1007
|
+
}
|
|
1008
|
+
/**
|
|
1009
|
+
* Start a server-mediated self-service reset (F040). POSTs the email + this app's
|
|
1010
|
+
* publishable key; the backend resolves the account, mints a single-use token, and
|
|
1011
|
+
* emails the link ITSELF — nothing comes back but a uniform 202. Resolves on success;
|
|
1012
|
+
* a 4xx (e.g. a misconfigured key) surfaces as a typed error for the developer.
|
|
1013
|
+
*/
|
|
1014
|
+
async requestPasswordReset(params) {
|
|
1015
|
+
const response = await this.transport.request({
|
|
1016
|
+
method: "POST",
|
|
1017
|
+
url: this.config.baseUrl + ENDPOINTS.forgotPassword,
|
|
1018
|
+
body: {
|
|
1019
|
+
email: params.email,
|
|
1020
|
+
client_id: this.config.key.raw
|
|
1021
|
+
}
|
|
1022
|
+
});
|
|
1023
|
+
if (response.status >= 400) throw this.toError(response);
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* Redeem a reset token (the opaque value from the emailed link) single-use and set
|
|
1027
|
+
* the new password. Returns a typed outcome the UI branches on; only a 5xx throws.
|
|
1028
|
+
*/
|
|
1029
|
+
async resetPassword(params) {
|
|
1030
|
+
const response = await this.transport.request({
|
|
1031
|
+
method: "POST",
|
|
1032
|
+
url: this.config.baseUrl + ENDPOINTS.resetPassword,
|
|
1033
|
+
body: {
|
|
1034
|
+
token: params.token,
|
|
1035
|
+
newPassword: params.newPassword
|
|
1036
|
+
}
|
|
1037
|
+
});
|
|
1038
|
+
if (response.status >= 500) throw this.toError(response);
|
|
1039
|
+
return parseResetOutcome(response.status, response.body);
|
|
1040
|
+
}
|
|
838
1041
|
async exchangeCode(params) {
|
|
839
1042
|
const response = await this.transport.request({
|
|
840
1043
|
method: "POST",
|
|
@@ -844,12 +1047,33 @@ var AuthClient = class {
|
|
|
844
1047
|
code: params.code,
|
|
845
1048
|
code_verifier: params.codeVerifier,
|
|
846
1049
|
redirect_uri: params.redirectUri,
|
|
847
|
-
|
|
1050
|
+
client_id: this.config.key.raw
|
|
848
1051
|
}
|
|
849
1052
|
});
|
|
850
1053
|
if (response.status !== 200) throw this.toError(response);
|
|
851
1054
|
return normalizeExchange(response.body, this.clock.now());
|
|
852
1055
|
}
|
|
1056
|
+
/**
|
|
1057
|
+
* Fetch this app's login-page branding (public, pk_-scoped). NEVER throws and never
|
|
1058
|
+
* blocks the sign-in form: any non-200, malformed body, or network failure resolves
|
|
1059
|
+
* to an empty token set so the caller keeps its built-in defaults (FR-BRND-3).
|
|
1060
|
+
*/
|
|
1061
|
+
async fetchBranding(params = {}) {
|
|
1062
|
+
try {
|
|
1063
|
+
const url = new URL(this.config.baseUrl + ENDPOINTS.branding);
|
|
1064
|
+
url.searchParams.set("client_id", this.config.key.raw);
|
|
1065
|
+
if (params.organizationCode) url.searchParams.set("organizationCode", params.organizationCode);
|
|
1066
|
+
const response = await this.transport.request({
|
|
1067
|
+
method: "GET",
|
|
1068
|
+
url: url.toString()
|
|
1069
|
+
});
|
|
1070
|
+
if (response.status !== 200) return {};
|
|
1071
|
+
const tokens = response.body?.tokens;
|
|
1072
|
+
return tokens && typeof tokens === "object" ? tokens : {};
|
|
1073
|
+
} catch {
|
|
1074
|
+
return {};
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
853
1077
|
/** The `RefreshTokens` function the SessionEngine (F003) injects. */
|
|
854
1078
|
createRefresher() {
|
|
855
1079
|
return async (current) => {
|
|
@@ -862,6 +1086,11 @@ var AuthClient = class {
|
|
|
862
1086
|
return normalizeRefresh(response.body, current, this.clock.now());
|
|
863
1087
|
};
|
|
864
1088
|
}
|
|
1089
|
+
/**
|
|
1090
|
+
* Ecosystem logout — only revokes when the caller carries the browser's ecosystem
|
|
1091
|
+
* cookie (a top-level hop). From a server-to-server context it clears nothing and
|
|
1092
|
+
* still answers 200; use `revokeSession` there.
|
|
1093
|
+
*/
|
|
865
1094
|
async logout(scope = "device") {
|
|
866
1095
|
const response = await this.transport.request({
|
|
867
1096
|
method: "POST",
|
|
@@ -870,6 +1099,22 @@ var AuthClient = class {
|
|
|
870
1099
|
});
|
|
871
1100
|
if (response.status >= 400) throw this.toError(response);
|
|
872
1101
|
}
|
|
1102
|
+
/**
|
|
1103
|
+
* Revoke a session using the access token as proof — the only logout that works
|
|
1104
|
+
* server-side, where no browser cookie reaches poly-auth. `device` ends this session;
|
|
1105
|
+
* `everywhere` ends every session for the user (FR-SSO-5).
|
|
1106
|
+
*
|
|
1107
|
+
* The token goes in the Authorization header and nowhere else: repeating it in the
|
|
1108
|
+
* body would only widen where it can leak.
|
|
1109
|
+
*/
|
|
1110
|
+
async revokeSession(accessToken, scope = "device") {
|
|
1111
|
+
const response = await this.transport.request({
|
|
1112
|
+
method: "POST",
|
|
1113
|
+
url: this.config.baseUrl + (scope === "everywhere" ? ENDPOINTS.authLogoutAll : ENDPOINTS.authLogout),
|
|
1114
|
+
headers: { Authorization: `Bearer ${accessToken}` }
|
|
1115
|
+
});
|
|
1116
|
+
if (response.status >= 400) throw this.toError(response);
|
|
1117
|
+
}
|
|
873
1118
|
async resolveOrg(email) {
|
|
874
1119
|
const response = await this.transport.request({
|
|
875
1120
|
method: "POST",
|
|
@@ -899,4 +1144,4 @@ var AuthClient = class {
|
|
|
899
1144
|
const PACKAGE_NAME = "@poly-x/core";
|
|
900
1145
|
const version = "0.0.0";
|
|
901
1146
|
//#endregion
|
|
902
|
-
export { AuthClient, DEFAULT_RETRY_POLICY, DEFAULT_TIMEOUT_MS, ENDPOINTS, FetchTransport, ForbiddenError, INITIAL_STATE as INITIAL_SESSION_STATE, InMemoryLock, InMemoryPkceStore, InMemorySessionChannel, InMemorySessionStore, MockTransport, PACKAGE_NAME, PolyXConfigError, PolyXError, RateLimitedError, SessionEngine, SessionExpiredError, SessionRevokedError, SystemClock, UnauthenticatedError, UpstreamError, ValidationError, computeChallenge, createSessionEngine, decodeJwtExp, generatePkce, isTransientStatus, mapPlatformError, normalizeExchange, normalizeRefresh, parseAuthorizeOutcome, parsePolyXKey, randomState, redactSensitive, reduce as reduceSessionState, resolveConfig, version };
|
|
1147
|
+
export { AuthClient, DEFAULT_RETRY_POLICY, DEFAULT_TIMEOUT_MS, ENDPOINTS, FetchTransport, ForbiddenError, INITIAL_STATE as INITIAL_SESSION_STATE, InMemoryLock, InMemoryPkceStore, InMemorySessionChannel, InMemorySessionStore, MockTransport, PACKAGE_NAME, PolyXConfigError, PolyXError, RateLimitedError, SessionEngine, SessionExpiredError, SessionRevokedError, SystemClock, UnauthenticatedError, UpstreamError, ValidationError, computeChallenge, createSessionEngine, decodeJwtExp, generatePkce, isTransientStatus, mapPlatformError, normalizeExchange, normalizeRefresh, parseAuthorizeOutcome, parsePasswordOutcome, parsePolyXKey, parseResetOutcome, randomState, redactSensitive, reduce as reduceSessionState, resolveConfig, version };
|