@wtfalch/auth 0.12.0 → 0.13.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 +3 -3
- package/dist/auth.d.ts +20 -47
- package/dist/auth.js +40 -9
- package/dist/browser.d.ts +1 -1
- package/dist/config.d.ts +58 -0
- package/dist/config.js +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/namespace-session.d.ts +2 -1
- package/dist/namespace-session.js +121 -1
- package/dist/next.d.ts +2 -2
- package/dist/testing.d.ts +31 -0
- package/dist/testing.js +81 -0
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -99,11 +99,11 @@ again. `forget()` sets the same mark, so a sign-out does not undo itself.
|
|
|
99
99
|
`completeSignIn()` at the callback returns `signed-in`, `silent-refused` — the
|
|
100
100
|
answer to a silent attempt, not a failure — or `error`.
|
|
101
101
|
|
|
102
|
-
###
|
|
102
|
+
### Multi-session seams: a non-default opener, storage, refresh and scopes
|
|
103
103
|
|
|
104
104
|
Four options on `BrowserAuthOptions` exist for a client that is not a
|
|
105
105
|
plain same-origin web page — a phone driving a system browser, a desktop app,
|
|
106
|
-
or
|
|
106
|
+
or an app juggling more than one issuer session. Every default matches the
|
|
107
107
|
behaviour above exactly, so a caller that sets none of them sees no change.
|
|
108
108
|
|
|
109
109
|
```ts
|
|
@@ -124,7 +124,7 @@ const auth = createBrowserAuth({
|
|
|
124
124
|
`{ get, set, drop }` shape — `get` synchronous, `set`/`drop` allowed to
|
|
125
125
|
return a promise (see "Storage: `get` stays synchronous" below). Two
|
|
126
126
|
`createBrowserAuth` instances given different stores never see each other's
|
|
127
|
-
values — one
|
|
127
|
+
values — one app's session cannot leak into another's held in the same tab.
|
|
128
128
|
- **`refresh()`**, a new method on the returned client, posts the
|
|
129
129
|
`refresh_token` grant and stores the session it gets back — awaiting the
|
|
130
130
|
store's write before it resolves, so an app that awaits `refresh()` knows
|
package/dist/auth.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type JWTPayload } from 'jose';
|
|
2
2
|
import { type AuthRequest, type NewUser, type PeoplePage } from './broker.js';
|
|
3
|
-
import { type AuthOptions } from './config.js';
|
|
3
|
+
import { type AccessTokenResult, type AuthOptions } from './config.js';
|
|
4
4
|
import { type SetCookie } from './cookies.js';
|
|
5
5
|
import type { SessionRecord } from './sessions.js';
|
|
6
6
|
export interface User {
|
|
@@ -163,33 +163,6 @@ export interface QrPollResult {
|
|
|
163
163
|
reason?: string;
|
|
164
164
|
cookies: SetCookie[];
|
|
165
165
|
}
|
|
166
|
-
/**
|
|
167
|
-
* Why `accessToken` refused: no session cookie, or one that will not open
|
|
168
|
-
* (`no_session`); a session with no refresh token (`no_refresh_token`); or
|
|
169
|
-
* the issuer refusing the refresh grant itself, including the same
|
|
170
|
-
* organisation/id-token validation `readCookie`'s refresh path applies
|
|
171
|
-
* (`refused`). A transient outage is not in this list -- `accessToken`
|
|
172
|
-
* throws for that instead, see its own doc comment.
|
|
173
|
-
*/
|
|
174
|
-
export type AccessTokenReason = 'no_session' | 'no_refresh_token' | 'refused';
|
|
175
|
-
export type AccessTokenResult = {
|
|
176
|
-
ok: true;
|
|
177
|
-
/** The issuer's own bearer token. Never the id token, and never what was in the cookie. */
|
|
178
|
-
accessToken: string;
|
|
179
|
-
/** Epoch milliseconds. */
|
|
180
|
-
expiresAt: number;
|
|
181
|
-
/**
|
|
182
|
-
* The re-sealed session -- always exactly one cookie, even when the
|
|
183
|
-
* refresh token itself did not rotate, because the grant always
|
|
184
|
-
* returns a fresh id token too. Persist it, e.g. as a `Set-Cookie`,
|
|
185
|
-
* before the value passed in is used again: the refresh token in it
|
|
186
|
-
* is spent.
|
|
187
|
-
*/
|
|
188
|
-
cookies: SetCookie[];
|
|
189
|
-
} | {
|
|
190
|
-
ok: false;
|
|
191
|
-
reason: AccessTokenReason;
|
|
192
|
-
};
|
|
193
166
|
export interface Auth {
|
|
194
167
|
readonly sessionCookieName: string;
|
|
195
168
|
/** GET {basePath}/start?next=&intent= : begins the OIDC flow; the issuer sends the browser to {basePath}/login?authRequest= */
|
|
@@ -415,33 +388,33 @@ export interface Auth {
|
|
|
415
388
|
*/
|
|
416
389
|
pollQrSignIn(request: Request): Promise<QrPollResult>;
|
|
417
390
|
/**
|
|
418
|
-
* Trades a sealed session
|
|
419
|
-
*
|
|
420
|
-
*
|
|
421
|
-
* token -- so this runs the refresh grant itself
|
|
422
|
-
* straight off that response, applying the same
|
|
423
|
-
* checks `readCookie`'s own refresh path
|
|
424
|
-
* `Oidc.refresh`.
|
|
391
|
+
* Trades a sealed session's `value` (`readCookie`'s own `value` parameter,
|
|
392
|
+
* not a `Cookie` header -- in namespace mode, the handle) for a fresh
|
|
393
|
+
* issuer access token. Nothing here ever holds one -- `Tokens` keeps only
|
|
394
|
+
* an id token and a refresh token -- so this runs the refresh grant itself
|
|
395
|
+
* and reads `access_token` straight off that response, applying the same
|
|
396
|
+
* id-token and organisation checks `readCookie`'s own refresh path
|
|
397
|
+
* applies, through the same `Oidc.refresh`.
|
|
425
398
|
*
|
|
426
399
|
* The refresh token is SPENT on every call: the issuer rotates it on each
|
|
427
|
-
* grant.
|
|
428
|
-
* would race the same token -- and persist `cookies`, the
|
|
429
|
-
* session this always returns on `ok: true` (a fresh id token
|
|
430
|
-
* on every grant, even on the rare one that does not rotate the
|
|
431
|
-
* token), before the session is read or `accessToken` is called
|
|
400
|
+
* grant. Cookie mode: a caller must serialise calls for one session -- two
|
|
401
|
+
* in flight would race the same token -- and persist `cookies`, the
|
|
402
|
+
* re-sealed session this always returns on `ok: true` (a fresh id token
|
|
403
|
+
* comes back on every grant, even on the rare one that does not rotate the
|
|
404
|
+
* refresh token), before the session is read or `accessToken` is called
|
|
405
|
+
* again. Namespace mode: `cookies` is always empty, and the call itself
|
|
406
|
+
* serialises under the record's own lease -- the same one a due `read`
|
|
407
|
+
* refreshes under -- so two calls, or a call racing a due read, spend the
|
|
408
|
+
* token in turn rather than together.
|
|
432
409
|
*
|
|
433
410
|
* `ok: false` is a definitive refusal -- no session, no refresh token in
|
|
434
411
|
* it, or the issuer refusing the grant (`isOAuthRefusal`, or the same
|
|
435
412
|
* organisation/id-token check `readCookie` applies). A transient issuer
|
|
436
413
|
* outage (network error, 5xx, 429) throws instead of answering
|
|
437
414
|
* `ok: false`: unlike `readCookie`, there is no still-valid access token
|
|
438
|
-
* to fall back to, because one was never kept.
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
* server-held record, refreshed under a lease (see `namespace-session.ts`)
|
|
442
|
-
* that serialises rotation across replicas. Spending the refresh token
|
|
443
|
-
* here would run outside that lease and could race, or duplicate, the
|
|
444
|
-
* rotation the lease exists to prevent. Throws when `namespace` is set.
|
|
415
|
+
* to fall back to, because one was never kept, in either mode.
|
|
416
|
+
* Namespace mode also throws while the binding is suspended, and when
|
|
417
|
+
* another holder keeps the record's lease past the wait.
|
|
445
418
|
*/
|
|
446
419
|
accessToken(cookieValue: string): Promise<AccessTokenResult>;
|
|
447
420
|
}
|
package/dist/auth.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { errors } from 'jose';
|
|
2
2
|
import * as client from 'openid-client';
|
|
3
3
|
import { Broker, BrokerError } from './broker.js';
|
|
4
|
-
import { resolveOptions } from './config.js';
|
|
4
|
+
import { resolveOptions, } from './config.js';
|
|
5
5
|
import { clearQrTransaction, clearSession, clearTransaction, cookieFrom, openQrTransaction, openRecord, openSession, openTransaction, qrTransactionCookieName, sealQrTransaction, sealSession, sealTransaction, sessionCookieName, transactionCookieName, } from './cookies.js';
|
|
6
6
|
import { namespaceSessions } from './namespace-session.js';
|
|
7
7
|
import { AuthError, ORG_CLAIM, Oidc, isOAuthRefusal, scopeFor } from './oidc.js';
|
|
@@ -19,6 +19,26 @@ export function createAuth(input) {
|
|
|
19
19
|
}
|
|
20
20
|
return resolved;
|
|
21
21
|
};
|
|
22
|
+
const emit = (event) => {
|
|
23
|
+
try {
|
|
24
|
+
// The type says void, but nothing stops a caller from handing over an
|
|
25
|
+
// async function -- audit logging is usually a database write. An
|
|
26
|
+
// async throw never reaches this catch (calling an async function
|
|
27
|
+
// cannot throw synchronously, whatever is on its first line), so a
|
|
28
|
+
// thenable return is caught too, or a careless callback would produce
|
|
29
|
+
// an unhandled rejection instead of the logged-and-ignored failure
|
|
30
|
+
// promised above.
|
|
31
|
+
const result = options().onAuthEvent?.(event);
|
|
32
|
+
if (result && typeof result.then === 'function') {
|
|
33
|
+
Promise.resolve(result).catch((error) => {
|
|
34
|
+
console.error('@wtfalch/auth: onAuthEvent threw', error);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
console.error('@wtfalch/auth: onAuthEvent threw', error);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
22
42
|
const boundUser = (claims) => {
|
|
23
43
|
const namespace = options().namespace;
|
|
24
44
|
return {
|
|
@@ -460,7 +480,11 @@ export function createAuth(input) {
|
|
|
460
480
|
const resendVerification = (email, next) => quietly(() => broker().sendVerification(email, safeNextPath(next, '')));
|
|
461
481
|
const requestPasswordReset = (email, next) => quietly(() => broker().sendReset(email, safeNextPath(next, '')));
|
|
462
482
|
const sendLink = (email, next) => quietly(() => broker().sendLink(email, safeNextPath(next, '')));
|
|
463
|
-
const invite = (input) =>
|
|
483
|
+
const invite = async (input) => {
|
|
484
|
+
const result = await broker().invite({ ...input, next: safeNextPath(input.next, '') });
|
|
485
|
+
emit({ type: 'invite', email: input.email, userId: result.userId, invited: result.invited });
|
|
486
|
+
return result;
|
|
487
|
+
};
|
|
464
488
|
const resetPassword = async ({ userId, code, password, next, }, requestHeaders) => {
|
|
465
489
|
try {
|
|
466
490
|
const flow = await serverFlow();
|
|
@@ -505,21 +529,29 @@ export function createAuth(input) {
|
|
|
505
529
|
};
|
|
506
530
|
const signIn = async ({ authRequestId, email, password, }) => {
|
|
507
531
|
try {
|
|
508
|
-
|
|
532
|
+
const redirectTo = await broker().signIn({ authRequestId, email, password });
|
|
533
|
+
emit({ type: 'signIn', email });
|
|
534
|
+
return { ok: true, redirectTo };
|
|
509
535
|
}
|
|
510
536
|
catch (error) {
|
|
511
|
-
if (needsHostedLogin(error))
|
|
537
|
+
if (needsHostedLogin(error)) {
|
|
538
|
+
emit({ type: 'signIn', email });
|
|
512
539
|
return { ok: true, redirectTo: hostedUrl(authRequestId), hosted: true };
|
|
540
|
+
}
|
|
513
541
|
return { ok: false, error: signInError(error) };
|
|
514
542
|
}
|
|
515
543
|
};
|
|
516
544
|
const signUp = async ({ authRequestId, ...user }) => {
|
|
517
545
|
try {
|
|
518
|
-
|
|
546
|
+
const redirectTo = await broker().signUp({ authRequestId, ...user });
|
|
547
|
+
emit({ type: 'signUp', email: user.email });
|
|
548
|
+
return { ok: true, redirectTo };
|
|
519
549
|
}
|
|
520
550
|
catch (error) {
|
|
521
|
-
if (needsHostedLogin(error))
|
|
551
|
+
if (needsHostedLogin(error)) {
|
|
552
|
+
emit({ type: 'signUp', email: user.email });
|
|
522
553
|
return { ok: true, redirectTo: hostedUrl(authRequestId), hosted: true };
|
|
554
|
+
}
|
|
523
555
|
if (error instanceof BrokerError && error.error === 'email_taken') {
|
|
524
556
|
return { ok: false, error: 'email_taken', message: error.detail ?? error.error };
|
|
525
557
|
}
|
|
@@ -707,9 +739,8 @@ export function createAuth(input) {
|
|
|
707
739
|
return { status: 'signed_in', cookies: [cleared, session] };
|
|
708
740
|
};
|
|
709
741
|
const accessToken = async (cookieValue) => {
|
|
710
|
-
if (options().namespace)
|
|
711
|
-
|
|
712
|
-
}
|
|
742
|
+
if (options().namespace)
|
|
743
|
+
return sessions.accessToken(cookieValue);
|
|
713
744
|
const session = await openSession(options(), cookieValue);
|
|
714
745
|
if (!session)
|
|
715
746
|
return { ok: false, reason: 'no_session' };
|
package/dist/browser.d.ts
CHANGED
|
@@ -85,7 +85,7 @@ export interface BrowserAuthOptions {
|
|
|
85
85
|
/**
|
|
86
86
|
* Holds the token, verifier, return path and silent-tried mark. Default
|
|
87
87
|
* wraps `sessionStorage` and never throws. Two instances given different
|
|
88
|
-
* stores never see each other's values — inject one per
|
|
88
|
+
* stores never see each other's values — inject one per app.
|
|
89
89
|
*/
|
|
90
90
|
store?: BrowserAuthStore;
|
|
91
91
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -1,5 +1,55 @@
|
|
|
1
|
+
import type { SetCookie } from './cookies.js';
|
|
1
2
|
import { type NamespaceSelection, resolveNamespaceContext } from './namespaces.js';
|
|
2
3
|
import type { SessionStore } from './sessions.js';
|
|
4
|
+
/**
|
|
5
|
+
* An auth lifecycle event, for audit logging. Each variant carries only
|
|
6
|
+
* what the call that fires it already has in hand -- `signIn`/`signUp`
|
|
7
|
+
* resolve to a redirect, not a decoded identity, so there is no `userId`
|
|
8
|
+
* to offer there; `invite` calls the broker's own endpoint, which returns
|
|
9
|
+
* one.
|
|
10
|
+
*/
|
|
11
|
+
export type AuthEvent = {
|
|
12
|
+
type: 'signIn';
|
|
13
|
+
email: string;
|
|
14
|
+
} | {
|
|
15
|
+
type: 'signUp';
|
|
16
|
+
email: string;
|
|
17
|
+
} | {
|
|
18
|
+
type: 'invite';
|
|
19
|
+
email: string;
|
|
20
|
+
userId: string;
|
|
21
|
+
invited: boolean;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Why `accessToken` refused: no session (no cookie in cookie mode, no live
|
|
25
|
+
* record behind the namespace handle in namespace mode), a session with no
|
|
26
|
+
* refresh token (`no_refresh_token`), or the issuer refusing the refresh
|
|
27
|
+
* grant itself, including the same organisation/id-token validation the
|
|
28
|
+
* ordinary refresh path applies (`refused`). A transient outage is not in
|
|
29
|
+
* this list -- `accessToken` throws for that instead, see its own doc
|
|
30
|
+
* comment.
|
|
31
|
+
*/
|
|
32
|
+
export type AccessTokenReason = 'no_session' | 'no_refresh_token' | 'refused';
|
|
33
|
+
export type AccessTokenResult = {
|
|
34
|
+
ok: true;
|
|
35
|
+
/** The issuer's own bearer token. Never the id token, and never what was in the cookie. */
|
|
36
|
+
accessToken: string;
|
|
37
|
+
/** Epoch milliseconds. */
|
|
38
|
+
expiresAt: number;
|
|
39
|
+
/**
|
|
40
|
+
* Cookie mode: always exactly one, even when the refresh token itself
|
|
41
|
+
* did not rotate, because the grant always returns a fresh id token
|
|
42
|
+
* too. Persist it, e.g. as a `Set-Cookie`, before the value passed in
|
|
43
|
+
* is used again: the refresh token in it is spent.
|
|
44
|
+
*
|
|
45
|
+
* Namespace mode: always empty. The record updates itself under its
|
|
46
|
+
* own lease; there is nothing for the caller to persist.
|
|
47
|
+
*/
|
|
48
|
+
cookies: SetCookie[];
|
|
49
|
+
} | {
|
|
50
|
+
ok: false;
|
|
51
|
+
reason: AccessTokenReason;
|
|
52
|
+
};
|
|
3
53
|
export interface AuthOptions {
|
|
4
54
|
/** Opt-in namespace mode. Resolve this from trusted workspace/host configuration. */
|
|
5
55
|
namespace?: NamespaceSelection;
|
|
@@ -59,6 +109,13 @@ export interface AuthOptions {
|
|
|
59
109
|
*/
|
|
60
110
|
freshness?: number;
|
|
61
111
|
fetch?: typeof fetch;
|
|
112
|
+
/**
|
|
113
|
+
* Called after a sign-in, a sign-up, or an invite succeeds, so an app can
|
|
114
|
+
* add audit logging without threading it through every call site by hand.
|
|
115
|
+
* Never awaited; a throw from it is caught and logged rather than failing
|
|
116
|
+
* the call it rode in on.
|
|
117
|
+
*/
|
|
118
|
+
onAuthEvent?: (event: AuthEvent) => void;
|
|
62
119
|
}
|
|
63
120
|
export interface ResolvedOptions {
|
|
64
121
|
namespace: ReturnType<typeof resolveNamespaceContext> | null;
|
|
@@ -93,6 +150,7 @@ export interface ResolvedOptions {
|
|
|
93
150
|
freshness: number;
|
|
94
151
|
secure: boolean;
|
|
95
152
|
fetch: typeof fetch;
|
|
153
|
+
onAuthEvent: ((event: AuthEvent) => void) | null;
|
|
96
154
|
}
|
|
97
155
|
export declare const ISSUER = "https://auth.wtfalch.dev";
|
|
98
156
|
export declare function resolveOptions(options: AuthOptions): ResolvedOptions;
|
package/dist/config.js
CHANGED
|
@@ -59,6 +59,7 @@ export function resolveOptions(options) {
|
|
|
59
59
|
freshness: seconds(options.freshness, 'freshness', 5 * 60),
|
|
60
60
|
secure: appUrl.protocol === 'https:',
|
|
61
61
|
fetch: options.fetch ?? globalThis.fetch,
|
|
62
|
+
onAuthEvent: options.onAuthEvent ?? null,
|
|
62
63
|
};
|
|
63
64
|
}
|
|
64
65
|
function required(value, name) {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export { type
|
|
2
|
-
export { type AuthOptions, ISSUER } from './config.js';
|
|
1
|
+
export { type Auth, type AuthRequest, type Gate, type GateRules, type Intent, type MaxAge, type NewUser, type PasskeyRegistrationResult, type PasskeyRegistrationStart, type PasskeySignInStart, type Prompt, type QrPollResult, type QrPollStatus, type QrSignInStart, type ReadResult, type ResetError, type SignInError, type SignInResult, type SignUpError, type SignUpResult, type SignedIn, type User, createAuth, } from './auth.js';
|
|
2
|
+
export { type AccessTokenReason, type AccessTokenResult, type AuthEvent, type AuthOptions, ISSUER, } from './config.js';
|
|
3
3
|
export type { SetCookie } from './cookies.js';
|
|
4
4
|
export type { SessionRecord, SessionStore, SessionUpdate } from './sessions.js';
|
|
5
5
|
export { BrokerError } from './broker.js';
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { JWTPayload } from 'jose';
|
|
2
2
|
import { type Broker } from './broker.js';
|
|
3
|
-
import type { ResolvedOptions } from './config.js';
|
|
3
|
+
import type { AccessTokenResult, ResolvedOptions } from './config.js';
|
|
4
4
|
import { type SetCookie } from './cookies.js';
|
|
5
5
|
import { type Oidc, type Tokens } from './oidc.js';
|
|
6
6
|
import type { SessionRecord } from './sessions.js';
|
|
@@ -42,4 +42,5 @@ export declare function namespaceSessions(deps: {
|
|
|
42
42
|
listSessions: (subject: string) => Promise<SessionRecord[]>;
|
|
43
43
|
endSession: (key: string, subject: string) => Promise<boolean>;
|
|
44
44
|
keyOf: (value: string | undefined) => Promise<string | null>;
|
|
45
|
+
accessToken: (value: string | undefined) => Promise<AccessTokenResult>;
|
|
45
46
|
};
|
|
@@ -266,6 +266,116 @@ export function namespaceSessions(deps) {
|
|
|
266
266
|
tokens: { k: key, idt: fresh.idToken, rt: fresh.refreshToken },
|
|
267
267
|
};
|
|
268
268
|
};
|
|
269
|
+
/**
|
|
270
|
+
* A fresh access token for this namespace session, under the record's own
|
|
271
|
+
* lease -- so this can never race the rotation a due `read` triggers, and
|
|
272
|
+
* two calls for the same session serialise rather than spend the refresh
|
|
273
|
+
* token twice.
|
|
274
|
+
*
|
|
275
|
+
* Unlike `refresh`, this can never settle for "another request just
|
|
276
|
+
* refreshed it, that generation bump is good enough": the access token
|
|
277
|
+
* from that grant was never kept (`RecordPayload` holds only an id token
|
|
278
|
+
* and a refresh token, see `namespace-session.ts`'s own module comment),
|
|
279
|
+
* so the only way to answer is a grant this call runs itself. It waits
|
|
280
|
+
* out whoever holds the lease first, the same wait `refresh` does, but
|
|
281
|
+
* always performs its own grant once it takes the lease rather than
|
|
282
|
+
* reusing `refreshLeased`'s "give up and use what's there" shortcut.
|
|
283
|
+
*/
|
|
284
|
+
const accessToken = async (value) => {
|
|
285
|
+
const key = await keyOf(value);
|
|
286
|
+
if (!key)
|
|
287
|
+
return { ok: false, reason: 'no_session' };
|
|
288
|
+
// A suspended binding mints nothing, the same gate `read` and every
|
|
289
|
+
// sign-in entry point apply. It throws rather than answering `ok: false`,
|
|
290
|
+
// as `startSignIn` does: a suspension is not a verdict on this session,
|
|
291
|
+
// and the fail-closed answer while the service is unreachable is not
|
|
292
|
+
// definitive either.
|
|
293
|
+
if (!(await bindingActive()))
|
|
294
|
+
throw new AuthError('unavailable', 'the namespace binding is not active');
|
|
295
|
+
let current = await load(key);
|
|
296
|
+
if (!current)
|
|
297
|
+
return { ok: false, reason: 'no_session' };
|
|
298
|
+
if (!current.tokens.rt)
|
|
299
|
+
return { ok: false, reason: 'no_refresh_token' };
|
|
300
|
+
for (let attempt = 0;; attempt++) {
|
|
301
|
+
const { record } = current;
|
|
302
|
+
const leased = record.leaseUntil !== null && record.leaseUntil > now();
|
|
303
|
+
if (!leased) {
|
|
304
|
+
const generation = record.generation + 1;
|
|
305
|
+
const acquired = await store().advance(key, record.generation, {
|
|
306
|
+
payload: record.payload,
|
|
307
|
+
expiresAt: record.expiresAt,
|
|
308
|
+
leaseUntil: now() + LEASE_SECONDS,
|
|
309
|
+
});
|
|
310
|
+
if (acquired)
|
|
311
|
+
return runGrant(key, current, generation);
|
|
312
|
+
}
|
|
313
|
+
// Still leased after the wait: someone else's grant is in flight, or a
|
|
314
|
+
// crashed holder's lease has not run out yet. Neither is a verdict on
|
|
315
|
+
// this session, so this throws like a transient issuer outage rather
|
|
316
|
+
// than answering `refused`, which a caller may treat as signed out.
|
|
317
|
+
if (attempt >= WAIT_STEPS)
|
|
318
|
+
throw new Error('@wtfalch/auth: accessToken timed out waiting for the session lease');
|
|
319
|
+
await sleep(WAIT_STEP_MS);
|
|
320
|
+
const latest = await load(key);
|
|
321
|
+
if (!latest)
|
|
322
|
+
return { ok: false, reason: 'no_session' };
|
|
323
|
+
if (!latest.tokens.rt)
|
|
324
|
+
return { ok: false, reason: 'no_refresh_token' };
|
|
325
|
+
current = latest;
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
/** The grant `accessToken` runs once it holds the lease itself. */
|
|
329
|
+
const runGrant = async (key, leased, generation) => {
|
|
330
|
+
const { record, tokens } = leased;
|
|
331
|
+
let fresh;
|
|
332
|
+
try {
|
|
333
|
+
fresh = await deps.oidc().refresh(tokens.rt);
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
await store()
|
|
337
|
+
.advance(key, generation, {
|
|
338
|
+
payload: record.payload,
|
|
339
|
+
expiresAt: record.expiresAt,
|
|
340
|
+
leaseUntil: null,
|
|
341
|
+
})
|
|
342
|
+
.catch(() => { });
|
|
343
|
+
if (definitive(error)) {
|
|
344
|
+
// The lease means nobody else spent the token: the issuer has ended it.
|
|
345
|
+
await store().revoke(key, now());
|
|
346
|
+
return { ok: false, reason: 'refused' };
|
|
347
|
+
}
|
|
348
|
+
// A network error or a 5xx: no access token was ever minted, and
|
|
349
|
+
// unlike a plain session read there is no still-valid one to fall
|
|
350
|
+
// back to -- so this throws, exactly as the cookie-mode accessToken
|
|
351
|
+
// does for the same case.
|
|
352
|
+
throw error;
|
|
353
|
+
}
|
|
354
|
+
const committed = await store().advance(key, generation, {
|
|
355
|
+
payload: await seal(key, fresh),
|
|
356
|
+
expiresAt: now() + options().sessionMaxAge,
|
|
357
|
+
leaseUntil: null,
|
|
358
|
+
});
|
|
359
|
+
if (!committed) {
|
|
360
|
+
// Revoked while the grant was in flight, or the lease ran out and
|
|
361
|
+
// another request took over. The fresh tokens are kept nowhere, so
|
|
362
|
+
// the refresh token is not left live if it rotated.
|
|
363
|
+
if (fresh.refreshToken !== tokens.rt)
|
|
364
|
+
revokeToken(fresh.refreshToken);
|
|
365
|
+
return { ok: false, reason: 'refused' };
|
|
366
|
+
}
|
|
367
|
+
if (!fresh.accessToken || fresh.accessTokenExpiresAt === undefined) {
|
|
368
|
+
// The issuer answered the grant but sent no access token -- treat it
|
|
369
|
+
// the same as a refusal rather than handing back an unusable result.
|
|
370
|
+
return { ok: false, reason: 'refused' };
|
|
371
|
+
}
|
|
372
|
+
return {
|
|
373
|
+
ok: true,
|
|
374
|
+
accessToken: fresh.accessToken,
|
|
375
|
+
expiresAt: fresh.accessTokenExpiresAt,
|
|
376
|
+
cookies: [],
|
|
377
|
+
};
|
|
378
|
+
};
|
|
269
379
|
/**
|
|
270
380
|
* The person behind this cookie. Never sets a cookie: a dead or revoked
|
|
271
381
|
* handle just reads as nobody until the next sign-in replaces it.
|
|
@@ -298,7 +408,17 @@ export function namespaceSessions(deps) {
|
|
|
298
408
|
return { kind: 'none' };
|
|
299
409
|
}
|
|
300
410
|
};
|
|
301
|
-
return {
|
|
411
|
+
return {
|
|
412
|
+
bindingActive,
|
|
413
|
+
create,
|
|
414
|
+
end,
|
|
415
|
+
read,
|
|
416
|
+
revokeSubject,
|
|
417
|
+
listSessions,
|
|
418
|
+
endSession,
|
|
419
|
+
keyOf,
|
|
420
|
+
accessToken,
|
|
421
|
+
};
|
|
302
422
|
}
|
|
303
423
|
/**
|
|
304
424
|
* These apps sit behind a proxy, so the client never reaches this process
|
package/dist/next.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type NextRequest, NextResponse } from 'next/server';
|
|
2
2
|
import { type Auth, type AuthRequest, type ChangePasswordResult, type DeletePasskeyResult, type EmailChangeResult, type GateRules, type Intent, type MaxAge, type NewUser, type Passkey, type PasskeyRegistrationResult, type PasskeyRegistrationStart, type PasskeySignInStart, type Prompt, type QrPollStatus, type ResetError, type SignInError, type SignUpError, type User } from './auth.js';
|
|
3
|
-
import type { AuthOptions } from './config.js';
|
|
3
|
+
import type { AuthEvent, AuthOptions } from './config.js';
|
|
4
4
|
import type { SessionRecord } from './sessions.js';
|
|
5
5
|
type SearchParams = Record<string, string | string[] | undefined>;
|
|
6
6
|
export interface QrSignIn {
|
|
@@ -157,4 +157,4 @@ export interface NextAuth {
|
|
|
157
157
|
}>;
|
|
158
158
|
}
|
|
159
159
|
export declare function nextAuth(options: AuthOptions): NextAuth;
|
|
160
|
-
export type { Auth, AuthOptions, AuthRequest, ChangePasswordResult, DeletePasskeyResult, EmailChangeResult, GateRules, Intent, MaxAge, NewUser, Passkey, PasskeyRegistrationResult, PasskeyRegistrationStart, PasskeySignInStart, Prompt, QrPollStatus, SessionRecord, User, };
|
|
160
|
+
export type { Auth, AuthEvent, AuthOptions, AuthRequest, ChangePasswordResult, DeletePasskeyResult, EmailChangeResult, GateRules, Intent, MaxAge, NewUser, Passkey, PasskeyRegistrationResult, PasskeyRegistrationStart, PasskeySignInStart, Prompt, QrPollStatus, SessionRecord, User, };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Auth, User } from './auth.js';
|
|
2
|
+
export declare function setSessionUser(user: User | null): void;
|
|
3
|
+
/** A User shaped like the SDK's, for `setSessionUser`. */
|
|
4
|
+
export declare function testUser(id: string, overrides?: Partial<User>): User;
|
|
5
|
+
export declare function getUser(): Promise<User | null>;
|
|
6
|
+
export declare function requireUser(_next?: string): Promise<User>;
|
|
7
|
+
export declare const handlers: () => never;
|
|
8
|
+
export declare const proxy: () => never;
|
|
9
|
+
export declare const authRequest: () => never;
|
|
10
|
+
export declare const signIn: () => never;
|
|
11
|
+
export declare const signUp: () => never;
|
|
12
|
+
export declare const requestPasswordReset: () => never;
|
|
13
|
+
export declare const resetPassword: () => never;
|
|
14
|
+
export declare const sendLink: () => never;
|
|
15
|
+
export declare const resendVerification: () => never;
|
|
16
|
+
/**
|
|
17
|
+
* The core SDK instance, as far as a device sign-in module reaches into it.
|
|
18
|
+
*
|
|
19
|
+
* The real one talks to the issuer. This one records what was asked and
|
|
20
|
+
* answers with whatever a test set, so a device/link sign-in flow can be
|
|
21
|
+
* exercised against a real database without one. Only the members named
|
|
22
|
+
* here are stubbed; anything else read off `auth` is a mistake worth a loud
|
|
23
|
+
* one.
|
|
24
|
+
*/
|
|
25
|
+
export declare const sentLinks: {
|
|
26
|
+
email: string;
|
|
27
|
+
next?: string;
|
|
28
|
+
}[];
|
|
29
|
+
export declare function setFollowLinkResult(result: unknown): void;
|
|
30
|
+
export declare function setCookieUser(user: User | null): void;
|
|
31
|
+
export declare const auth: Auth;
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A stub for whatever `@/lib/auth` resolves to in an app's integration test
|
|
3
|
+
* tier. The real module reads a sealed cookie through `next/headers`, which
|
|
4
|
+
* does not load outside a Next.js build; this one answers with whatever
|
|
5
|
+
* `setSessionUser` last set, so a test can be "signed in" as one person,
|
|
6
|
+
* call the app's own authorization logic for real, then sign in as another.
|
|
7
|
+
* Nothing here talks to the issuer.
|
|
8
|
+
*
|
|
9
|
+
* Alias the app's auth module to `@wtfalch/auth/testing` for that tier (for
|
|
10
|
+
* example in a vitest config's `resolve.alias`), or re-export from it.
|
|
11
|
+
*/
|
|
12
|
+
let current = null;
|
|
13
|
+
export function setSessionUser(user) {
|
|
14
|
+
current = user;
|
|
15
|
+
}
|
|
16
|
+
/** A User shaped like the SDK's, for `setSessionUser`. */
|
|
17
|
+
export function testUser(id, overrides = {}) {
|
|
18
|
+
return {
|
|
19
|
+
id,
|
|
20
|
+
email: `${id}@example.test`,
|
|
21
|
+
emailVerified: true,
|
|
22
|
+
name: `Person ${id}`,
|
|
23
|
+
givenName: null,
|
|
24
|
+
familyName: null,
|
|
25
|
+
preferredUsername: null,
|
|
26
|
+
organizationId: 'org-test',
|
|
27
|
+
// Never fresh, which is what a stubbed session should be: the issuer only
|
|
28
|
+
// owes `auth_time` when the request asked for it, and nothing here asks.
|
|
29
|
+
authTime: null,
|
|
30
|
+
claims: {},
|
|
31
|
+
...overrides,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export async function getUser() {
|
|
35
|
+
return current;
|
|
36
|
+
}
|
|
37
|
+
export async function requireUser(_next) {
|
|
38
|
+
if (!current)
|
|
39
|
+
throw new Error('requireUser: no session set; call setSessionUser first');
|
|
40
|
+
return current;
|
|
41
|
+
}
|
|
42
|
+
function unavailable(name) {
|
|
43
|
+
return () => {
|
|
44
|
+
throw new Error(`${name} is not available in the integration tier`);
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export const handlers = unavailable('handlers');
|
|
48
|
+
export const proxy = unavailable('proxy');
|
|
49
|
+
export const authRequest = unavailable('authRequest');
|
|
50
|
+
export const signIn = unavailable('signIn');
|
|
51
|
+
export const signUp = unavailable('signUp');
|
|
52
|
+
export const requestPasswordReset = unavailable('requestPasswordReset');
|
|
53
|
+
export const resetPassword = unavailable('resetPassword');
|
|
54
|
+
export const sendLink = unavailable('sendLink');
|
|
55
|
+
export const resendVerification = unavailable('resendVerification');
|
|
56
|
+
/**
|
|
57
|
+
* The core SDK instance, as far as a device sign-in module reaches into it.
|
|
58
|
+
*
|
|
59
|
+
* The real one talks to the issuer. This one records what was asked and
|
|
60
|
+
* answers with whatever a test set, so a device/link sign-in flow can be
|
|
61
|
+
* exercised against a real database without one. Only the members named
|
|
62
|
+
* here are stubbed; anything else read off `auth` is a mistake worth a loud
|
|
63
|
+
* one.
|
|
64
|
+
*/
|
|
65
|
+
export const sentLinks = [];
|
|
66
|
+
let linkResult = { ok: false, error: 'invalid_code', message: 'no result set' };
|
|
67
|
+
let cookieUser = null;
|
|
68
|
+
export function setFollowLinkResult(result) {
|
|
69
|
+
linkResult = result;
|
|
70
|
+
}
|
|
71
|
+
export function setCookieUser(user) {
|
|
72
|
+
cookieUser = user;
|
|
73
|
+
}
|
|
74
|
+
export const auth = {
|
|
75
|
+
sessionCookieName: '__Host-wtfalch_auth',
|
|
76
|
+
sendLink: async (email, next) => {
|
|
77
|
+
sentLinks.push({ email, next });
|
|
78
|
+
},
|
|
79
|
+
followLink: async () => linkResult,
|
|
80
|
+
readCookie: async () => ({ user: cookieUser, cookies: [] }),
|
|
81
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wtfalch/auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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",
|
|
@@ -37,6 +37,10 @@
|
|
|
37
37
|
"./sessions": {
|
|
38
38
|
"types": "./dist/sessions.d.ts",
|
|
39
39
|
"default": "./dist/sessions.js"
|
|
40
|
+
},
|
|
41
|
+
"./testing": {
|
|
42
|
+
"types": "./dist/testing.d.ts",
|
|
43
|
+
"default": "./dist/testing.js"
|
|
40
44
|
}
|
|
41
45
|
},
|
|
42
46
|
"sideEffects": false,
|