@lalternative/auth 0.13.3 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -186,3 +186,118 @@ if (isInvitationFailure(outcome)) return <InvitationNotice reason={outcome} />
186
186
 
187
187
  `endpoint` is any backend that redeems a token, so an app already claiming
188
188
  against its own API keeps doing so; `extra` adds fields to the request body.
189
+
190
+ ## Customer passwords at the identity provider (0.14.0, completed in 0.15.0)
191
+
192
+ From **0.14.0** — and working end to end from **0.15.0**, which relays the
193
+ password to the provider instead of dropping it — an app can move its customers' passwords to the suite's
194
+ identity provider while keeping its own login screen, its own domain and its
195
+ own session. Nothing is enabled by a version bump alone: passwords stay local
196
+ until `kratosPasswords` is passed. An app upgrading to 0.14.x never changes
197
+ behaviour by accident.
198
+
199
+ ```ts
200
+ createPlatformAuth({
201
+ // …
202
+ kratosPasswords: {
203
+ publicUrl: process.env.URBANGATE_PUBLIC_URL!,
204
+ issuer: process.env.URBANGATE_ISSUER_URL!,
205
+ clientId: process.env.URBANGATE_PROVISIONER_CLIENT_ID!,
206
+ clientSecret: process.env.URBANGATE_PROVISIONER_CLIENT_SECRET!,
207
+ role: "spore:user",
208
+ product: "spore",
209
+ onProvisioningDeferred: ({ userId, email }) => queueIdentityRepair(userId, email),
210
+ },
211
+ })
212
+ ```
213
+
214
+ The login form, its copy and its routes do not change, and nobody is
215
+ redirected: the password is posted to this app as before and checked against
216
+ Kratos instead of a local hash.
217
+
218
+ ### Each app keeps its own accounts
219
+
220
+ The same person signing up on two products gets two local users and two
221
+ passwords, which may use two different addresses. They are never told the
222
+ products know each other. What they share — when the address is the same — is
223
+ one identity at the provider, which is what an app key is issued against.
224
+
225
+ An app therefore **must not deactivate or delete the identity** when it
226
+ deletes a local account: it drops its own role and its local row. Deactivating
227
+ the identity would sign the person out of every other product of the suite.
228
+
229
+ The address is what joins the two, and nothing else does. Someone who signs up
230
+ on spore with one address and on lalter with another gets **two identities**,
231
+ and the provider has no way to know they are the same person. Their app keys
232
+ are then split across those identities: `/keys` shows each set on its own, and
233
+ a key minted under one cannot name the other's product. That follows from each
234
+ app keeping its own accounts, and is not a defect to route around — but an
235
+ integrator who used two addresses will meet it, and the answer is to sign up
236
+ with the same address on both products.
237
+
238
+ ### Refusals a form must tell apart
239
+
240
+ `res.error.message` carries the reason, so the existing error banner renders
241
+ it with no change. A page that routes rather than renders uses the predicates:
242
+
243
+ | Predicate | Meaning |
244
+ |---|---|
245
+ | `needsPasswordRecovery` | The identity has no password yet (an account predating the move). Send to recovery — it is **not** a wrong password. |
246
+ | `isIdentityProviderUnavailable` | The provider is unreachable. The password was never refused; do not suggest changing it. |
247
+ | `needsSecondFactor` | Kratos requires a second factor. |
248
+ | `isAccountDisabled` | The identity is deactivated. |
249
+
250
+ Verification fails closed: only an explicit refusal by Kratos reads as a wrong
251
+ password, and an outage answers 503 so nobody rotates a password that was
252
+ right.
253
+
254
+ ### The password reaches the provider, or the write fails
255
+
256
+ Kratos holds the password, so every write must reach it: sign-up, the OTP
257
+ reset and a password change all relay what the form collected before anything
258
+ is stored locally. Kratos hashes it with the hasher its own configuration
259
+ declares, so an app cannot hand over one it hashed itself.
260
+
261
+ If the provider is unreachable the write fails with 503 and nothing changes,
262
+ rather than storing a placeholder against a password Kratos never received —
263
+ that account could never be opened again, and nothing would say why.
264
+
265
+ This is the one place the package refuses rather than degrades. Reading
266
+ (signing in) fails closed too, but a sign-up that cannot reach the provider
267
+ still creates the local account: the person is registered, `identityId` stays
268
+ null, and `onProvisioningDeferred` hands the repair to the app. Writing a
269
+ password has no such fallback, because a password stored nowhere is not a
270
+ state a repair can fix.
271
+
272
+ ### Why the sentinel hash
273
+
274
+ Better Auth's `/sign-in/email` reads the credential row and refuses **before**
275
+ reaching the verifier when it carries no hash, and its verifier is handed only
276
+ `{hash, password}` — never the address. So the package writes
277
+ `KRATOS_SENTINEL_HASH` in place of a hash and carries the address to the
278
+ verifier from the route hook.
279
+
280
+ The sentinel is a constant, not a hash: argon2/bcrypt/scrypt verification of
281
+ it fails on its format, so a build that ever bypassed the custom verifier
282
+ refuses everyone rather than admitting anyone.
283
+
284
+ This is deliberate and it is a workaround. When Better Auth exposes a seam for
285
+ an external credential provider, the replacement is to handle `/sign-in/email`
286
+ before the native route runs, and the sentinel disappears. That was not taken
287
+ now because it means re-implementing session creation, which is where a
288
+ mistake becomes an authentication hole.
289
+
290
+ ### Provisioning is not atomic
291
+
292
+ `user.create.after` runs after the insert commits, so a sign-up cannot be
293
+ atomic with the identity it needs. A provider that is down leaves `identityId`
294
+ null and the person registered all the same — a customer is never refused
295
+ registration because the provider is unavailable. `onProvisioningDeferred`
296
+ receives those sign-ups so the app can queue the repair, which re-sends
297
+ through `provisionIdentity`. The endpoint is idempotent on the address, so a
298
+ repair for someone who already got an identity returns that same one.
299
+
300
+ ### Rollout
301
+
302
+ Per app, smallest customer base first — never all at once. An app that
303
+ switches and breaks locks its customers out.
package/dist/client.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createAuthClient } from 'better-auth/react';
2
- import { f as AuthClientSurface, l as MagicLinkClientSurface, S as SsoClientSurface, d as AdminClientSurface, T as TwoFactorClientSurface, P as PlatformAuthClientConfig } from './types-3OHf736K.js';
2
+ import { f as AuthClientSurface, l as MagicLinkClientSurface, S as SsoClientSurface, d as AdminClientSurface, T as TwoFactorClientSurface, P as PlatformAuthClientConfig } from './types-COX3VaBw.js';
3
3
  import 'better-auth';
4
4
 
5
5
  /**
package/dist/index.d.ts CHANGED
@@ -1,12 +1,12 @@
1
- import { L as LoginFormProps, R as RegisterFormProps, V as VerifyEmailFormProps, F as ForgotPasswordFormProps, M as MagicLinkFormProps, a as ResetPasswordFormProps, A as AuthLayoutProps, I as InvitationNoticeProps, b as AuthClientResult, c as LinkComponent } from './types-3OHf736K.js';
2
- export { d as AdminClientSurface, e as AuthClientDataResult, f as AuthClientSurface, g as AuthInviteProps, h as AuthNavProps, i as AuthThemeProps, j as InvitationFailure, k as LoginFormLabels, l as MagicLinkClientSurface, m as MagicLinkConfig, n as MagicLinkFormLabels, P as PlatformAuthClientConfig, o as PlatformAuthConfig, p as PlatformAuthMailer, q as PlatformAuthMailerArgs, r as PlatformAuthMailerType, s as PlatformRateLimitConfig, t as PlatformRateLimitRule, u as PlatformSession, v as PlatformSessionData, w as PlatformSsoConfig, x as PlatformTwoFactorConfig, y as PlatformUser, z as RegisterFormLabels, S as SsoClientSurface, T as TwoFactorClientSurface } from './types-3OHf736K.js';
1
+ import { L as LoginFormProps, R as RegisterFormProps, V as VerifyEmailFormProps, F as ForgotPasswordFormProps, M as MagicLinkFormProps, a as ResetPasswordFormProps, A as AuthLayoutProps, I as InvitationNoticeProps, b as AuthClientResult, c as LinkComponent } from './types-COX3VaBw.js';
2
+ export { d as AdminClientSurface, e as AuthClientDataResult, f as AuthClientSurface, g as AuthInviteProps, h as AuthNavProps, i as AuthThemeProps, j as InvitationFailure, k as LoginFormLabels, l as MagicLinkClientSurface, m as MagicLinkConfig, n as MagicLinkFormLabels, P as PlatformAuthClientConfig, o as PlatformAuthConfig, p as PlatformAuthMailer, q as PlatformAuthMailerArgs, r as PlatformAuthMailerType, s as PlatformKratosPasswordConfig, t as PlatformRateLimitConfig, u as PlatformRateLimitRule, v as PlatformSession, w as PlatformSessionData, x as PlatformSsoConfig, y as PlatformTwoFactorConfig, z as PlatformUser, B as RegisterFormLabels, S as SsoClientSurface, T as TwoFactorClientSurface } from './types-COX3VaBw.js';
3
3
  import * as better_auth_react from 'better-auth/react';
4
4
  import * as better_auth from 'better-auth';
5
5
  import { PlatformAuthClient } from './client.js';
6
6
  export { startSso } from './client.js';
7
7
  import * as react from 'react';
8
8
  import { InputHTMLAttributes, ReactNode } from 'react';
9
- export { C as ClaimOutcome, S as SsoMappedUser, a as SsoProfile, i as isInvitationFailure, m as mapSsoProfile } from './sso-profile-HO-u17jd.js';
9
+ export { C as ClaimOutcome, S as SsoMappedUser, a as SsoProfile, i as isInvitationFailure, m as mapSsoProfile } from './sso-profile-aNyHaZHJ.js';
10
10
 
11
11
  /**
12
12
  * Returns a useSession hook bound to the given auth client.
@@ -221,6 +221,23 @@ declare function withInviteToken(href: string, token?: string): string;
221
221
  */
222
222
  declare function isEmailNotVerified(error: AuthClientResult["error"]): boolean;
223
223
 
224
+ /**
225
+ * Whether the sign-in was refused because the person's identity carries no
226
+ * password yet at the provider — an account that predates the move, whose
227
+ * owner sets a password once through the recovery flow.
228
+ *
229
+ * It is not a wrong password, and rendering it as one sends the person
230
+ * retrying an old password that will never work again.
231
+ */
232
+ declare function needsPasswordRecovery(error: AuthClientResult["error"]): boolean;
233
+ /**
234
+ * Whether the identity provider could not be reached. The password was never
235
+ * refused: the person retries, and must not be told to change it.
236
+ */
237
+ declare function isIdentityProviderUnavailable(error: AuthClientResult["error"]): boolean;
238
+ declare function needsSecondFactor(error: AuthClientResult["error"]): boolean;
239
+ declare function isAccountDisabled(error: AuthClientResult["error"]): boolean;
240
+
224
241
  type OAuthErrorLabels = {
225
242
  accountNotLinked: string;
226
243
  socialCancelled: string;
@@ -326,4 +343,4 @@ interface AuthLinkProps {
326
343
  */
327
344
  declare function AuthLink({ to, as: Link, className, children }: AuthLinkProps): react.JSX.Element;
328
345
 
329
- export { AuthClientResult, AuthField, type AuthFieldProps, AuthLayout, AuthLayoutProps, AuthLink, AuthSubmit, ForgotPasswordForm, ForgotPasswordFormProps, InvitationNotice, InvitationNoticeProps, LinkComponent, LoginForm, LoginFormProps, type MagicLinkErrorLabels, MagicLinkForm, MagicLinkFormProps, type OAuthErrorLabels, RegisterForm, RegisterFormProps, ResetPasswordForm, ResetPasswordFormProps, SocialButtons, VerifyEmailForm, VerifyEmailFormProps, clearOAuthError, initialMagicLinkError, initialOAuthError, isEmailNotVerified, isMagicLinkError, magicLinkErrorCallback, magicLinkErrorMessage, normalizeInviteToken, oauthErrorCallback, oauthErrorMessage, useLogout, useSession, withInviteToken };
346
+ export { AuthClientResult, AuthField, type AuthFieldProps, AuthLayout, AuthLayoutProps, AuthLink, AuthSubmit, ForgotPasswordForm, ForgotPasswordFormProps, InvitationNotice, InvitationNoticeProps, LinkComponent, LoginForm, LoginFormProps, type MagicLinkErrorLabels, MagicLinkForm, MagicLinkFormProps, type OAuthErrorLabels, RegisterForm, RegisterFormProps, ResetPasswordForm, ResetPasswordFormProps, SocialButtons, VerifyEmailForm, VerifyEmailFormProps, clearOAuthError, initialMagicLinkError, initialOAuthError, isAccountDisabled, isEmailNotVerified, isIdentityProviderUnavailable, isMagicLinkError, magicLinkErrorCallback, magicLinkErrorMessage, needsPasswordRecovery, needsSecondFactor, normalizeInviteToken, oauthErrorCallback, oauthErrorMessage, useLogout, useSession, withInviteToken };
package/dist/index.js CHANGED
@@ -1514,6 +1514,20 @@ function InvitationNotice({
1514
1514
  action
1515
1515
  ] });
1516
1516
  }
1517
+
1518
+ // src/kratos-sign-in-error.ts
1519
+ function needsPasswordRecovery(error) {
1520
+ return error?.code === "IDENTITY_HAS_NO_PASSWORD";
1521
+ }
1522
+ function isIdentityProviderUnavailable(error) {
1523
+ return error?.code === "IDENTITY_PROVIDER_UNAVAILABLE" || error?.status === 503;
1524
+ }
1525
+ function needsSecondFactor(error) {
1526
+ return error?.code === "SECOND_FACTOR_REQUIRED";
1527
+ }
1528
+ function isAccountDisabled(error) {
1529
+ return error?.code === "ACCOUNT_DISABLED";
1530
+ }
1517
1531
  export {
1518
1532
  AuthField,
1519
1533
  AuthLayout,
@@ -1530,12 +1544,16 @@ export {
1530
1544
  clearOAuthError,
1531
1545
  initialMagicLinkError,
1532
1546
  initialOAuthError,
1547
+ isAccountDisabled,
1533
1548
  isEmailNotVerified,
1549
+ isIdentityProviderUnavailable,
1534
1550
  isInvitationFailure,
1535
1551
  isMagicLinkError,
1536
1552
  magicLinkErrorCallback,
1537
1553
  magicLinkErrorMessage,
1538
1554
  mapSsoProfile,
1555
+ needsPasswordRecovery,
1556
+ needsSecondFactor,
1539
1557
  normalizeInviteToken,
1540
1558
  oauthErrorCallback,
1541
1559
  oauthErrorMessage,