@erpgulf/auth-sdk 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,357 @@
1
+ # `@erpgulf/auth-sdk`
2
+
3
+ Framework-independent TypeScript SDK for the shared ERPGulf authentication flow.
4
+ It hides Frappe endpoints, master-token handling, response formats, and policy
5
+ terminology from consuming applications.
6
+
7
+ The package has no React, React Native, or Expo dependency. It uses global
8
+ `fetch` by default and can accept a custom transport for compatible runtimes and
9
+ tests.
10
+
11
+ > `@erpgulf/auth-sdk` does **not** manage user token refresh or session
12
+ > persistence.
13
+
14
+ ## Installation
15
+
16
+ The beta is distributed publicly through npmjs.org. After publication succeeds,
17
+ install the exact version without registry credentials:
18
+
19
+ ```sh
20
+ npm install --save-exact @erpgulf/auth-sdk@0.1.0-beta.1
21
+ ```
22
+
23
+ Or with pnpm:
24
+
25
+ ```sh
26
+ pnpm add --save-exact @erpgulf/auth-sdk@0.1.0-beta.1
27
+ ```
28
+
29
+ If you previously configured `@erpgulf` for GitHub Packages, replace that scope
30
+ mapping in the consuming project's `.npmrc` so it overrides any old user-level
31
+ configuration:
32
+
33
+ ```ini
34
+ @erpgulf:registry=https://registry.npmjs.org
35
+ ```
36
+
37
+ New projects using the default npm registry need no `.npmrc` setup. Public
38
+ distribution does not grant access to ERPGulf backend tenants.
39
+
40
+ This prerelease is for integration testing. Consumer and staging validation
41
+ remain outstanding; public availability does not make it a stable release. The
42
+ package provides ESM JavaScript and TypeScript declarations and requires Node.js
43
+ 20 or newer for Node consumers.
44
+
45
+ ## Create a client
46
+
47
+ Supply the backend URL at runtime. Each client owns an independent normalized
48
+ URL and in-memory master-token cache.
49
+
50
+ ```ts
51
+ import { createAuthClient } from "@erpgulf/auth-sdk";
52
+
53
+ const auth = createAuthClient({
54
+ baseUrl: "https://customer.example.com",
55
+ timeoutMs: 10_000,
56
+ metadata: {
57
+ appId: "employee-app",
58
+ appVersion: "2.4.0",
59
+ },
60
+ });
61
+ ```
62
+
63
+ HTTPS is required by default. Local development over HTTP must be explicitly
64
+ enabled with `allowInsecureHttp: true`. Base URLs may include custom ports, but
65
+ credentials, query strings, fragments, and non-root paths are rejected.
66
+
67
+ Server discovery, QR scanning, and company-code lookup happen before client
68
+ creation and are outside this package.
69
+
70
+ ## Authentication flow
71
+
72
+ `begin` fetches and normalizes login policy, then runs the single authoritative
73
+ flow resolver:
74
+
75
+ ```ts
76
+ const flow = await auth.begin({ mobileNumber: "5550001" });
77
+
78
+ flow.action; // "SIGN_UP" | "SIGN_IN"
79
+ flow.nextStep;
80
+ flow.credentials.password; // "required" | "optional" | "disabled"
81
+ flow.credentials.otp; // "required" | "optional" | "disabled"
82
+ flow.credentials.atLeastOneOf; // e.g. ["password", "otp"]
83
+ ```
84
+
85
+ `atLeastOneOf` describes enabled optional credentials when the backend does not
86
+ mark either one mandatory. This preserves `Optional` OTP semantics instead of
87
+ silently converting it to required. Contradictory or credential-free policies
88
+ fail with `UNSUPPORTED_POLICY`.
89
+
90
+ Applications can use `nextStep` for common UI routing and the full credential
91
+ requirements for optional inputs. Presentation remains entirely app-owned.
92
+ `nextStep` is a hint, not a complete form definition: inspect `credentials` for
93
+ optional fields and `policy.employeeHasExistingPassword` to distinguish creating
94
+ a password from verifying one, including when both credentials are required.
95
+
96
+ Prefer `begin` for policy-guided integration. `getLoginPolicy` is the
97
+ lower-level alternative for apps with their own resolver; calling both is
98
+ unnecessary. Neither flow hints nor low-level operations enforce backend
99
+ authorization. The full policy matrix, especially password-only flows without an
100
+ existing password, requires backend confirmation before rollout. Unit tests
101
+ describe the current resolver, not proof that every combination is supported by
102
+ the server.
103
+
104
+ ## Login policy
105
+
106
+ ```ts
107
+ const policy = await auth.getLoginPolicy({ mobileNumber: "5550001" });
108
+ ```
109
+
110
+ Backend values `Mandatory`, `Optional`, and `No` become `required`, `optional`,
111
+ and `disabled`. Raw Frappe payloads and strings are not returned.
112
+
113
+ The supplied backend example uses a GET request with a form body.
114
+ Standards-based `fetch` does not support GET bodies, so the default adapter
115
+ sends the safely encoded mobile number as `?mobile=...`, while preserving GET
116
+ and the documented content-type. The existing `origin/expo-auth-starter` example
117
+ at commit `fd8b565` sends POST form data; it does not prove GET-query support.
118
+ Verify query-parameter compatibility against an authorized staging backend
119
+ before rollout. The SDK has not switched methods based on that conflicting
120
+ example.
121
+
122
+ ## Master token behavior
123
+
124
+ The SDK automatically acquires a master access token for login policy, OTP, and
125
+ sign-in requests. It:
126
+
127
+ - validates the confirmed response with Zod;
128
+ - caches the access token only in the client instance's memory;
129
+ - calculates expiry from `expires_in` and uses a 30-second safety skew;
130
+ - deduplicates simultaneous acquisition into one request;
131
+ - never logs, persists, or publicly exposes the credential;
132
+ - ignores the returned master refresh token and calls the master-token endpoint
133
+ again when required.
134
+
135
+ Only HTTP 401 with `exc_type: "AuthenticationError"` and without a
136
+ `status: "error"` business-error marker triggers invalidation, fresh
137
+ acquisition, and one retry. This middleware signal comes from the existing app
138
+ example, not a live-backend test. Bare 401, all 403 responses, and credential
139
+ errors do not trigger automatic replay. A second matching rejection is
140
+ `MASTER_TOKEN_REJECTED`. There are no general network, timeout,
141
+ malformed-response, or 5xx retries.
142
+
143
+ Safe replay of OTP/sign-in still requires the backend to emit that middleware
144
+ signal **before processing the operation**. Confirm this contract in staging.
145
+ Client serialization does not expose the internal master-token cache. This is
146
+ accidental-disclosure protection, not isolation from malicious application code.
147
+
148
+ ## Send OTP
149
+
150
+ ```ts
151
+ const otp = await auth.sendOtp({ mobileNumber: "5550001" });
152
+ otp.expiresIn; // seconds
153
+ ```
154
+
155
+ The backend success message is not used for business logic. A timeout or unknown
156
+ network outcome is not automatically replayed, avoiding duplicate OTP actions.
157
+
158
+ ## Sign up
159
+
160
+ Sign-up is not sent with master Authorization under the confirmed contract.
161
+ Optional credentials are omitted rather than encoded as `undefined`. The
162
+ existing app example does send master Authorization for sign-up; reconcile this
163
+ discrepancy in staging. The SDK retains the supplied contract for now.
164
+
165
+ ```ts
166
+ const result = await auth.signUp({
167
+ mobileNumber: "5550001",
168
+ otp: "<OTP>",
169
+ password: "<PASSWORD>", // omit when policy allows
170
+ });
171
+ ```
172
+
173
+ At least one credential must be present. The SDK does not retain either value or
174
+ compare the returned phone number with the submitted number.
175
+
176
+ ## Sign in
177
+
178
+ Sign-in is master-token authenticated. Its confirmed credential combinations
179
+ are:
180
+
181
+ ```ts
182
+ await auth.signIn({ mobileNumber, password });
183
+ await auth.signIn({ mobileNumber, otp });
184
+ await auth.signIn({ mobileNumber, password, otp });
185
+ ```
186
+
187
+ Calling sign-in with neither password nor OTP fails locally with
188
+ `INVALID_SIGN_IN_INPUT`. Values are form encoded, and absent fields are omitted.
189
+ Sign-in is never replayed after an ambiguous network failure because a submitted
190
+ OTP may already have been consumed. The only automatic retry is the bounded
191
+ explicit master-auth rejection path described above.
192
+
193
+ These are low-level operations: they do not fetch or enforce policy implicitly.
194
+ Passwords are not trimmed. If the employee has no self-chosen password, a
195
+ supplied password may **set** it rather than verify identity. Do not treat a
196
+ password-only request in that state as verified proof of identity; confirm the
197
+ backend's onboarding rules and enforce authorization on the server.
198
+
199
+ ## Results and app-owned session state
200
+
201
+ Sign-up returns `AuthResult`; sign-in returns `SignInResult`, which adds the
202
+ normalized password and OTP policies reported by sign-in.
203
+
204
+ ```ts
205
+ const result = await auth.signIn({ mobileNumber, password });
206
+
207
+ result.token.accessToken;
208
+ result.token.refreshToken;
209
+ result.token.expiresIn;
210
+ result.employee;
211
+ result.authenticatedAt;
212
+ result.passwordPolicy;
213
+ result.otpPolicy;
214
+
215
+ // SDK responsibility ends here.
216
+ await appSessionManager.save({
217
+ accessToken: result.token.accessToken,
218
+ refreshToken: result.token.refreshToken,
219
+ });
220
+ ```
221
+
222
+ Token type casing from the backend is normalized to `Bearer` when it is a
223
+ case-insensitive bearer value. The SDK returns user tokens but never stores,
224
+ refreshes, or rotates them.
225
+
226
+ ## Errors
227
+
228
+ All SDK failures use `AuthError`:
229
+
230
+ ```ts
231
+ import { AuthError } from "@erpgulf/auth-sdk";
232
+
233
+ try {
234
+ await auth.signIn({ mobileNumber, otp });
235
+ } catch (error: unknown) {
236
+ if (error instanceof AuthError) {
237
+ console.log(error.code, error.retryable, error.httpStatus);
238
+ }
239
+ }
240
+ ```
241
+
242
+ Confirmed sign-in errors are normalized as follows:
243
+
244
+ | Backend meaning | SDK code |
245
+ | ------------------------------ | ------------------------ |
246
+ | Invalid or expired OTP | `INVALID_OR_EXPIRED_OTP` |
247
+ | Invalid password | `INVALID_PASSWORD` |
248
+ | Unknown authentication failure | `AUTHENTICATION_FAILED` |
249
+
250
+ Malformed confirmed responses use `INVALID_RESPONSE`; network and timeout errors
251
+ use `NETWORK_ERROR` and `TIMEOUT`. Error messages never include response
252
+ payloads, credentials, or Authorization headers. Native/custom transport
253
+ exceptions are replaced with safe messages; their raw causes are not attached
254
+ because they can contain request credentials. Custom transports must return HTTP
255
+ errors as responses, not throw them.
256
+
257
+ For dispatched `sendOtp`, `signUp`, and `signIn` requests, network failures,
258
+ timeouts, 5xx responses, and malformed successes have `retryable: false`: the
259
+ operation may already have taken effect. Do not automatically repeat it. A
260
+ retryable acquisition failure can occur before the protected mutation is sent;
261
+ read-only policy failures may also be retryable. The flag does not promise that
262
+ an error will resolve on retry.
263
+
264
+ Response schemas validate required fields, success markers, and known policy
265
+ values while stripping extra object fields. Additive backend metadata is
266
+ accepted but is not exposed to consumers; unknown policy enum values still fail
267
+ closed.
268
+
269
+ ## Custom transport
270
+
271
+ `HttpTransport` and its request/response types are public only to support
272
+ runtimes without compatible global `fetch` and deterministic tests. It
273
+ transports data; it must not implement user-session token storage or refresh.
274
+ The default transport requests `redirect: "error"`, `cache: "no-store"`, and
275
+ `credentials: "omit"`. It does not follow redirects or opt into browser cookies
276
+ or HTTP caching. Custom transports must provide equivalent protection and must
277
+ not automatically retry requests. They receive sensitive headers and bodies and
278
+ are part of the trusted application boundary. Throw `AuthError` with code
279
+ `TIMEOUT` for timeouts; other thrown values become `NETWORK_ERROR`.
280
+
281
+ Browser/Node fetch behavior does not prove React Native/Expo adapter behavior.
282
+ Verify these options on each target runtime; use a conforming transport where
283
+ the runtime ignores them. The backend must also return `Cache-Control: no-store`
284
+ for authentication responses to protect intermediaries and native caches.
285
+
286
+ ## Security and explicit non-goals
287
+
288
+ The SDK does not log authentication data. Do not log request bodies or returned
289
+ results in consuming apps. Validate/allowlist URLs from untrusted QR codes or
290
+ company lookup before client creation: HTTPS and URL syntax checks cannot
291
+ establish a server's trustworthiness.
292
+
293
+ This package intentionally does not implement:
294
+
295
+ - user access/refresh-token storage, refresh, or rotation;
296
+ - session restoration, global auth state, logout, or business API interception;
297
+ - SecureStore, Keychain, or AsyncStorage;
298
+ - React, React Native, Expo, hooks, navigation, or UI;
299
+ - server/company/tenant discovery or app-specific branches.
300
+
301
+ ## Development
302
+
303
+ From the repository root:
304
+
305
+ ```sh
306
+ pnpm install
307
+ pnpm lint
308
+ pnpm format:check
309
+ pnpm typecheck
310
+ pnpm test
311
+ pnpm build
312
+ ```
313
+
314
+ Tests use fake credentials and mock transports, plus an ephemeral loopback HTTP
315
+ server to verify native-fetch redirect rejection. They never contact a live
316
+ backend. Running the suite requires permission to bind a loopback port.
317
+
318
+ ## Versioning
319
+
320
+ The initial public npm testing version is `0.1.0-beta.1`, published with the
321
+ `beta` dist-tag. The first release uses an authenticated npm maintainer;
322
+ subsequent releases can use the repository's manually dispatched
323
+ trusted-publishing workflow. Fixes use `0.1.0-beta.2`, `0.1.0-beta.3`, and
324
+ subsequent beta increments. Record consumer-visible changes with
325
+ `pnpm changeset`. Stable `0.1.0` must wait for validation in real consuming
326
+ applications; the beta workflow rejects stable versions and never publishes with
327
+ `latest`.
328
+
329
+ ## Backend contracts still needed
330
+
331
+ Success shapes are validated against supplied contract examples, not live calls,
332
+ and sign-in's two known credential errors are mapped. Representative error
333
+ responses are still needed for invalid mobile, OTP generation rate limiting,
334
+ invalid/expired master token, sign-up failures (including already signed up),
335
+ and login-policy failures. Until confirmed, those cases use safe generic errors.
336
+
337
+ Before rollout, use an authorized staging tenant and disposable test employees:
338
+
339
+ - Verify GET `?mobile=...` policy lookup with a known test number, including a
340
+ leading `+`; compare with an approved POST-form control if GET fails. Never
341
+ send a GET body with standards-based fetch.
342
+ - Capture sanitized expired-master, invalid-OTP, invalid-password, and
343
+ permission errors. Confirm the exact middleware discriminator and that
344
+ rejected master authentication cannot generate OTPs, consume OTPs, or set
345
+ passwords.
346
+ - Reconcile sign-up Authorization requirements and all supported combinations of
347
+ signed-up/existing-password flags and password/OTP policies. Include Optional
348
+ OTP, disabled credentials, and password creation versus verification.
349
+ - Verify OTP-only, password-only, and combined sign-in with disposable accounts;
350
+ confirm expiry units, token casing, nullable email, and normalized results.
351
+ - Confirm the server sends no-store headers. Check redirect rejection, cookie
352
+ omission, caching, timeout behavior, and browser CORS on each deployment
353
+ runtime, especially React Native/Expo. Never use real credentials in redirect
354
+ tests.
355
+
356
+ Unit and loopback tests verify SDK behavior; supplied examples verify documented
357
+ shapes; none of these replace the staging checks above.
@@ -0,0 +1,125 @@
1
+ type PasswordPolicy = "required" | "optional" | "disabled";
2
+ type OtpPolicy = "required" | "optional" | "disabled";
3
+ interface AuthPolicy {
4
+ readonly employeeId: string;
5
+ readonly employeeHasExistingPassword: boolean;
6
+ readonly employeeHasSignedUp: boolean;
7
+ readonly passwordPolicy: PasswordPolicy;
8
+ readonly otpPolicy: OtpPolicy;
9
+ }
10
+
11
+ interface AuthToken {
12
+ readonly accessToken: string;
13
+ readonly refreshToken: string;
14
+ readonly expiresIn: number;
15
+ readonly tokenType: string;
16
+ readonly scope: string;
17
+ }
18
+ interface AuthEmployee {
19
+ readonly id: string;
20
+ readonly name: string;
21
+ readonly phone: string;
22
+ readonly email: string | null;
23
+ }
24
+ interface AuthResult {
25
+ readonly token: AuthToken;
26
+ readonly employee: AuthEmployee;
27
+ readonly authenticatedAt: string;
28
+ }
29
+ interface SignInResult extends AuthResult {
30
+ readonly passwordPolicy: PasswordPolicy;
31
+ readonly otpPolicy: OtpPolicy;
32
+ }
33
+ interface SendOtpResult {
34
+ readonly expiresIn: number;
35
+ }
36
+ interface GetLoginPolicyInput {
37
+ readonly mobileNumber: string;
38
+ }
39
+ interface SendOtpInput {
40
+ readonly mobileNumber: string;
41
+ }
42
+ interface SignUpInput {
43
+ readonly mobileNumber: string;
44
+ readonly otp?: string;
45
+ readonly password?: string;
46
+ }
47
+ interface SignInInput {
48
+ readonly mobileNumber: string;
49
+ readonly otp?: string;
50
+ readonly password?: string;
51
+ }
52
+
53
+ type AuthAction = "SIGN_UP" | "SIGN_IN";
54
+ type AuthNextStep = "CREATE_PASSWORD" | "ENTER_PASSWORD" | "ENTER_OTP" | "ENTER_PASSWORD_AND_OTP" | "CHOOSE_PASSWORD_OR_OTP";
55
+ type CredentialName = "password" | "otp";
56
+ interface CredentialRequirements {
57
+ readonly password: PasswordPolicy;
58
+ readonly otp: OtpPolicy;
59
+ readonly atLeastOneOf: readonly CredentialName[];
60
+ }
61
+ interface AuthFlow {
62
+ readonly action: AuthAction;
63
+ readonly nextStep: AuthNextStep;
64
+ readonly credentials: CredentialRequirements;
65
+ readonly policy: AuthPolicy;
66
+ }
67
+
68
+ type HttpMethod = "GET" | "POST";
69
+ interface HttpRequest {
70
+ readonly url: string;
71
+ readonly method: HttpMethod;
72
+ readonly headers?: Readonly<Record<string, string>>;
73
+ readonly body?: string;
74
+ readonly timeoutMs?: number;
75
+ }
76
+ interface HttpResponse {
77
+ readonly status: number;
78
+ readonly body: unknown;
79
+ }
80
+ interface HttpTransport {
81
+ /**
82
+ * Return HTTP failures as responses. Throw AuthError(TIMEOUT) for timeouts;
83
+ * other thrown values are normalized to NETWORK_ERROR without their cause.
84
+ * Implementations must reject redirects, disable auth-response caching and
85
+ * avoid ambient cookies. Credentials must never be logged or persisted.
86
+ */
87
+ request(request: HttpRequest): Promise<HttpResponse>;
88
+ }
89
+
90
+ interface AuthClientMetadata {
91
+ readonly appId?: string;
92
+ readonly appVersion?: string;
93
+ }
94
+ interface AuthClientConfig {
95
+ readonly baseUrl: string;
96
+ readonly timeoutMs?: number;
97
+ readonly transport?: HttpTransport;
98
+ readonly metadata?: AuthClientMetadata;
99
+ readonly allowInsecureHttp?: boolean;
100
+ }
101
+ interface AuthClient {
102
+ begin(input: GetLoginPolicyInput): Promise<AuthFlow>;
103
+ getLoginPolicy(input: GetLoginPolicyInput): Promise<AuthPolicy>;
104
+ sendOtp(input: SendOtpInput): Promise<SendOtpResult>;
105
+ signUp(input: SignUpInput): Promise<AuthResult>;
106
+ signIn(input: SignInInput): Promise<SignInResult>;
107
+ }
108
+
109
+ declare function createAuthClient(config: AuthClientConfig): AuthClient;
110
+
111
+ type AuthErrorCode = "AUTHENTICATION_FAILED" | "INVALID_BASE_URL" | "INVALID_CLIENT_CONFIG" | "INVALID_MOBILE" | "INVALID_OR_EXPIRED_OTP" | "INVALID_PASSWORD" | "INVALID_RESPONSE" | "INVALID_SIGN_IN_INPUT" | "INVALID_SIGN_UP_INPUT" | "MASTER_TOKEN_FAILED" | "MASTER_TOKEN_REJECTED" | "NETWORK_ERROR" | "SERVER_ERROR" | "TIMEOUT" | "UNSUPPORTED_POLICY";
112
+ interface AuthErrorOptions {
113
+ readonly cause?: unknown;
114
+ readonly httpStatus?: number;
115
+ readonly retryable?: boolean;
116
+ }
117
+ declare class AuthError extends Error {
118
+ readonly code: AuthErrorCode;
119
+ readonly httpStatus: number | undefined;
120
+ readonly retryable: boolean;
121
+ constructor(code: AuthErrorCode, message: string, options?: AuthErrorOptions);
122
+ }
123
+ declare function isAuthError(error: unknown): error is AuthError;
124
+
125
+ export { type AuthAction, type AuthClient, type AuthClientConfig, type AuthClientMetadata, type AuthEmployee, AuthError, type AuthErrorCode, type AuthErrorOptions, type AuthFlow, type AuthNextStep, type AuthPolicy, type AuthResult, type AuthToken, type CredentialName, type CredentialRequirements, type GetLoginPolicyInput, type HttpMethod, type HttpRequest, type HttpResponse, type HttpTransport, type OtpPolicy, type PasswordPolicy, type SendOtpInput, type SendOtpResult, type SignInInput, type SignInResult, type SignUpInput, createAuthClient, isAuthError };