@lalternative/auth 0.13.2 → 0.14.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
@@ -56,8 +56,10 @@ export const auth = createPlatformAuth({
56
56
  })
57
57
  ```
58
58
 
59
- The callback is `/api/auth/oauth2/callback/urbangate`; register it on the
60
- Hydra client. On the client, `startSso(authClient, { callbackURL: "/admin" })`
59
+ The callback is `/api/auth/callback/urbangate`; register it on the Hydra
60
+ client. The provider's endpoints are derived from the issuer, so the app boots
61
+ even when the issuer is unreachable; discovery only adds ID-token verification.
62
+ On the client, `startSso(authClient, { callbackURL: "/admin" })`
61
63
  starts the redirect (Better Auth 1.7 serves generic providers through
62
64
  `signIn.social`, so no client plugin is needed).
63
65
 
@@ -184,3 +186,99 @@ if (isInvitationFailure(outcome)) return <InvitationNotice reason={outcome} />
184
186
 
185
187
  `endpoint` is any backend that redeems a token, so an app already claiming
186
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)
191
+
192
+ From **0.14.0**, an app can move its customers' passwords to the suite's
193
+ identity provider while keeping its own login screen, its own domain and its
194
+ own session. Nothing is enabled by a version bump alone: passwords stay local
195
+ until `kratosPasswords` is passed. An app upgrading to 0.14.x never changes
196
+ behaviour by accident.
197
+
198
+ ```ts
199
+ createPlatformAuth({
200
+ // …
201
+ kratosPasswords: {
202
+ publicUrl: process.env.URBANGATE_PUBLIC_URL!,
203
+ issuer: process.env.URBANGATE_ISSUER_URL!,
204
+ clientId: process.env.URBANGATE_PROVISIONER_CLIENT_ID!,
205
+ clientSecret: process.env.URBANGATE_PROVISIONER_CLIENT_SECRET!,
206
+ role: "spore:user",
207
+ product: "spore",
208
+ onProvisioningDeferred: ({ userId, email }) => queueIdentityRepair(userId, email),
209
+ },
210
+ })
211
+ ```
212
+
213
+ The login form, its copy and its routes do not change, and nobody is
214
+ redirected: the password is posted to this app as before and checked against
215
+ Kratos instead of a local hash.
216
+
217
+ ### Each app keeps its own accounts
218
+
219
+ The same person signing up on two products gets two local users and two
220
+ passwords, which may use two different addresses. They are never told the
221
+ products know each other. What they share — when the address is the same — is
222
+ one identity at the provider, which is what an app key is issued against.
223
+
224
+ An app therefore **must not deactivate or delete the identity** when it
225
+ deletes a local account: it drops its own role and its local row. Deactivating
226
+ the identity would sign the person out of every other product of the suite.
227
+
228
+ The address is what joins the two, and nothing else does. Someone who signs up
229
+ on spore with one address and on lalter with another gets **two identities**,
230
+ and the provider has no way to know they are the same person. Their app keys
231
+ are then split across those identities: `/keys` shows each set on its own, and
232
+ a key minted under one cannot name the other's product. That follows from each
233
+ app keeping its own accounts, and is not a defect to route around — but an
234
+ integrator who used two addresses will meet it, and the answer is to sign up
235
+ with the same address on both products.
236
+
237
+ ### Refusals a form must tell apart
238
+
239
+ `res.error.message` carries the reason, so the existing error banner renders
240
+ it with no change. A page that routes rather than renders uses the predicates:
241
+
242
+ | Predicate | Meaning |
243
+ |---|---|
244
+ | `needsPasswordRecovery` | The identity has no password yet (an account predating the move). Send to recovery — it is **not** a wrong password. |
245
+ | `isIdentityProviderUnavailable` | The provider is unreachable. The password was never refused; do not suggest changing it. |
246
+ | `needsSecondFactor` | Kratos requires a second factor. |
247
+ | `isAccountDisabled` | The identity is deactivated. |
248
+
249
+ Verification fails closed: only an explicit refusal by Kratos reads as a wrong
250
+ password, and an outage answers 503 so nobody rotates a password that was
251
+ right.
252
+
253
+ ### Why the sentinel hash
254
+
255
+ Better Auth's `/sign-in/email` reads the credential row and refuses **before**
256
+ reaching the verifier when it carries no hash, and its verifier is handed only
257
+ `{hash, password}` — never the address. So the package writes
258
+ `KRATOS_SENTINEL_HASH` in place of a hash and carries the address to the
259
+ verifier from the route hook.
260
+
261
+ The sentinel is a constant, not a hash: argon2/bcrypt/scrypt verification of
262
+ it fails on its format, so a build that ever bypassed the custom verifier
263
+ refuses everyone rather than admitting anyone.
264
+
265
+ This is deliberate and it is a workaround. When Better Auth exposes a seam for
266
+ an external credential provider, the replacement is to handle `/sign-in/email`
267
+ before the native route runs, and the sentinel disappears. That was not taken
268
+ now because it means re-implementing session creation, which is where a
269
+ mistake becomes an authentication hole.
270
+
271
+ ### Provisioning is not atomic
272
+
273
+ `user.create.after` runs after the insert commits, so a sign-up cannot be
274
+ atomic with the identity it needs. A provider that is down leaves `identityId`
275
+ null and the person registered all the same — a customer is never refused
276
+ registration because the provider is unavailable. `onProvisioningDeferred`
277
+ receives those sign-ups so the app can queue the repair, which re-sends
278
+ through `provisionIdentity`. The endpoint is idempotent on the address, so a
279
+ repair for someone who already got an identity returns that same one.
280
+
281
+ ### Rollout
282
+
283
+ Per app, smallest customer base first — never all at once. An app that
284
+ 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,