@wtfalch/auth 0.13.0 → 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/dist/auth.d.ts CHANGED
@@ -131,15 +131,21 @@ export interface Completed {
131
131
  location: string;
132
132
  cookies: SetCookie[];
133
133
  }
134
- /** A person signed in from a link rather than from a flow the browser started. */
135
- export type SignedIn = {
134
+ /**
135
+ * A person signed in without the browser driving the flow -- from a link, a
136
+ * password reset, or headless credentials. `E` is whichever entry point's
137
+ * own error vocabulary applies: `ResetError` for a link or reset code (the
138
+ * default, so `followLink` and `resetPassword` are unchanged), `SignInError`
139
+ * for a password.
140
+ */
141
+ export type SignedIn<E = ResetError> = {
136
142
  ok: true;
137
143
  location: string;
138
144
  cookies: SetCookie[];
139
145
  hosted?: true;
140
146
  } | {
141
147
  ok: false;
142
- error: ResetError;
148
+ error: E;
143
149
  message: string;
144
150
  };
145
151
  export interface QrSignInStart {
@@ -202,6 +208,21 @@ export interface Auth {
202
208
  code: string;
203
209
  next?: string;
204
210
  }, requestHeaders?: Headers): Promise<SignedIn>;
211
+ /**
212
+ * `signIn` as data, for a caller with no browser to carry through the OIDC
213
+ * redirect -- a CLI, a desktop app, or a server signing in on a person's
214
+ * behalf. There is no `authRequestId` to receive, because this runs its
215
+ * own transaction and finishes it itself: `ok: true` already carries the
216
+ * session, with no `complete()` call left to make. The same two checks as
217
+ * `followLink` apply -- an `ok: true` with an empty `cookies` means the
218
+ * issuer handed off to hosted login, and the cookie name is fixed, so the
219
+ * caller decides where the session goes.
220
+ */
221
+ signInWithPassword(input: {
222
+ email: string;
223
+ password: string;
224
+ next?: string;
225
+ }, requestHeaders?: Headers): Promise<SignedIn<SignInError>>;
205
226
  /** Emails a fresh verification link. Says nothing about whether the address exists. */
206
227
  resendVerification(email: string, next?: string): Promise<void>;
207
228
  /** Emails a password-reset link to {basePath}/reset?userId=&code=. Says nothing about whether the address exists. */
package/dist/auth.js CHANGED
@@ -541,6 +541,25 @@ export function createAuth(input) {
541
541
  return { ok: false, error: signInError(error) };
542
542
  }
543
543
  };
544
+ const signInWithPassword = async ({ email, password, next }, requestHeaders) => {
545
+ try {
546
+ const flow = await serverFlow();
547
+ const completed = await finishOrHandoff(() => broker().signIn({ authRequestId: flow.id, email, password }), flow, safeNextPath(next, options().afterLogin), requestHeaders);
548
+ emit({ type: 'signIn', email });
549
+ return { ok: true, ...completed };
550
+ }
551
+ catch (error) {
552
+ const code = signInError(error);
553
+ // `signInError` collapses everything the broker can say into one of
554
+ // four codes; only a real `BrokerError` carries a sentence worth
555
+ // showing; a transient failure gets the same generic message every
556
+ // other headless entry point uses for `unavailable`.
557
+ const message = code !== 'unavailable' && error instanceof BrokerError
558
+ ? (error.detail ?? error.error)
559
+ : 'the sign-in service did not answer';
560
+ return { ok: false, error: code, message };
561
+ }
562
+ };
544
563
  const signUp = async ({ authRequestId, ...user }) => {
545
564
  try {
546
565
  const redirectTo = await broker().signUp({ authRequestId, ...user });
@@ -814,6 +833,7 @@ export function createAuth(input) {
814
833
  endSession,
815
834
  authRequest,
816
835
  signIn,
836
+ signInWithPassword,
817
837
  signUp,
818
838
  startPasskeyRegistration,
819
839
  finishPasskeyRegistration,
@@ -248,11 +248,30 @@ export function namespaceSessions(deps) {
248
248
  .catch(() => { });
249
249
  return stillValid ? leased : null;
250
250
  }
251
- const committed = await store().advance(key, generation, {
252
- payload: await seal(key, fresh),
253
- expiresAt: now() + options().sessionMaxAge,
254
- leaseUntil: null,
255
- });
251
+ let committed;
252
+ try {
253
+ committed = await store().advance(key, generation, {
254
+ payload: await seal(key, fresh),
255
+ expiresAt: now() + options().sessionMaxAge,
256
+ leaseUntil: null,
257
+ });
258
+ }
259
+ catch (error) {
260
+ // The commit itself threw (a store outage): the lease is still held.
261
+ // Best-effort release it, as the still-valid path above already does,
262
+ // so the session does not stay locked out for up to LEASE_SECONDS, and
263
+ // revoke a rotated refresh token that was never stored.
264
+ await store()
265
+ .advance(key, generation, {
266
+ payload: record.payload,
267
+ expiresAt: record.expiresAt,
268
+ leaseUntil: null,
269
+ })
270
+ .catch(() => { });
271
+ if (fresh.refreshToken !== tokens.rt)
272
+ revokeToken(fresh.refreshToken);
273
+ throw error;
274
+ }
256
275
  if (!committed) {
257
276
  // Revoked while we asked, or our lease ran out and another request took
258
277
  // over. These tokens are kept nowhere, so they are not left live.
@@ -351,11 +370,31 @@ export function namespaceSessions(deps) {
351
370
  // does for the same case.
352
371
  throw error;
353
372
  }
354
- const committed = await store().advance(key, generation, {
355
- payload: await seal(key, fresh),
356
- expiresAt: now() + options().sessionMaxAge,
357
- leaseUntil: null,
358
- });
373
+ let committed;
374
+ try {
375
+ committed = await store().advance(key, generation, {
376
+ payload: await seal(key, fresh),
377
+ expiresAt: now() + options().sessionMaxAge,
378
+ leaseUntil: null,
379
+ });
380
+ }
381
+ catch (error) {
382
+ // The commit itself threw (a store outage): the lease is still held.
383
+ // Best-effort release it, as the still-valid path in `refreshLeased`
384
+ // does, so the session does not stay locked out for up to
385
+ // LEASE_SECONDS, and revoke a rotated refresh token that was never
386
+ // stored.
387
+ await store()
388
+ .advance(key, generation, {
389
+ payload: record.payload,
390
+ expiresAt: record.expiresAt,
391
+ leaseUntil: null,
392
+ })
393
+ .catch(() => { });
394
+ if (fresh.refreshToken !== tokens.rt)
395
+ revokeToken(fresh.refreshToken);
396
+ throw error;
397
+ }
359
398
  if (!committed) {
360
399
  // Revoked while the grant was in flight, or the lease ran out and
361
400
  // another request took over. The fresh tokens are kept nowhere, so
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/auth",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "Sign in against auth.wtfalch.dev: a server session for a Next.js app, and a browser client with silent single sign-on across subdomains.",
5
5
  "repository": {
6
6
  "type": "git",