@wtfalch/auth 0.6.0 → 0.8.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 +15 -2
- package/dist/auth.d.ts +83 -0
- package/dist/auth.js +147 -7
- package/dist/broker.d.ts +27 -0
- package/dist/broker.js +17 -0
- package/dist/cookies.d.ts +14 -0
- package/dist/cookies.js +22 -0
- package/dist/index.d.ts +1 -1
- package/dist/namespace-server.js +7 -3
- package/dist/namespace-session.js +27 -2
- package/dist/namespaces.d.ts +76 -2
- package/dist/namespaces.js +58 -9
- package/dist/next.d.ts +49 -2
- package/dist/next.js +51 -0
- package/dist/oidc.d.ts +5 -1
- package/dist/oidc.js +12 -8
- package/dist/passkey-browser.d.ts +83 -0
- package/dist/passkey-browser.js +150 -0
- package/dist/qr.d.ts +23 -0
- package/dist/qr.js +75 -0
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -139,11 +139,24 @@ app URL, and refreshes itself when the id token is close to expiring.
|
|
|
139
139
|
`ORG_CLAIM` is enforced on every token: a token from another organisation is
|
|
140
140
|
refused even when the issuer and the signature are good.
|
|
141
141
|
|
|
142
|
+
## Passkeys and QR sign-in
|
|
143
|
+
|
|
144
|
+
Passkeys live on each app's own domain: the relying-party ID is the app's
|
|
145
|
+
origin, so one made on one app never signs in on another. `@wtfalch/auth/passkey`
|
|
146
|
+
is the browser half of the WebAuthn ceremony; `startPasskeyRegistration`,
|
|
147
|
+
`finishPasskeyRegistration`, `startPasskeySignIn` and `finishPasskeySignIn` are
|
|
148
|
+
the server half.
|
|
149
|
+
|
|
150
|
+
A phone that is already signed in can also approve a new device from a QR
|
|
151
|
+
code, the issuer's OAuth device grant opted into per namespace binding with
|
|
152
|
+
`qrSignIn: true`, through `startQrSignIn` and `pollQrSignIn`.
|
|
153
|
+
|
|
142
154
|
## Documentation
|
|
143
155
|
|
|
144
156
|
`docs/adopting.md` in [wtfalch/auth](https://github.com/wtfalch/auth) covers a
|
|
145
|
-
full adoption: the files to add, the email flows, invitations, refresh,
|
|
146
|
-
|
|
157
|
+
full adoption: the files to add, the email flows, invitations, refresh,
|
|
158
|
+
passkeys, signing in a new device from a QR code, and the checks to run
|
|
159
|
+
against a real issuer.
|
|
147
160
|
|
|
148
161
|
## Licence
|
|
149
162
|
|
package/dist/auth.d.ts
CHANGED
|
@@ -65,6 +65,26 @@ export type SignUpResult = {
|
|
|
65
65
|
error: SignUpError;
|
|
66
66
|
message: string;
|
|
67
67
|
};
|
|
68
|
+
export interface PasskeyRegistrationStart {
|
|
69
|
+
passkeyId: string;
|
|
70
|
+
/** ZITADEL's `publicKeyCredentialCreationOptions`, opaque here -- `@wtfalch/auth/passkey`'s `createPasskey` knows its shape. */
|
|
71
|
+
options: unknown;
|
|
72
|
+
}
|
|
73
|
+
export type PasskeyRegistrationResult = {
|
|
74
|
+
ok: true;
|
|
75
|
+
} | {
|
|
76
|
+
ok: false;
|
|
77
|
+
error: 'invalid';
|
|
78
|
+
};
|
|
79
|
+
export type PasskeySignInStart = {
|
|
80
|
+
ok: true;
|
|
81
|
+
sessionId: string;
|
|
82
|
+
/** ZITADEL's `publicKeyCredentialRequestOptions`, opaque here -- `@wtfalch/auth/passkey`'s `getPasskey` knows its shape. */
|
|
83
|
+
options: unknown;
|
|
84
|
+
} | {
|
|
85
|
+
ok: false;
|
|
86
|
+
error: SignInError;
|
|
87
|
+
};
|
|
68
88
|
export interface Completed {
|
|
69
89
|
location: string;
|
|
70
90
|
cookies: SetCookie[];
|
|
@@ -80,6 +100,27 @@ export type SignedIn = {
|
|
|
80
100
|
error: ResetError;
|
|
81
101
|
message: string;
|
|
82
102
|
};
|
|
103
|
+
export interface QrSignInStart {
|
|
104
|
+
/** The verification URL, complete with the user code -- what the QR code encodes. */
|
|
105
|
+
url: string;
|
|
106
|
+
/** The same code, for a person to type in by hand instead of scanning. */
|
|
107
|
+
userCode: string;
|
|
108
|
+
expiresIn: number;
|
|
109
|
+
/** Seconds between polls; the issuer's own pace. */
|
|
110
|
+
interval: number;
|
|
111
|
+
cookies: SetCookie[];
|
|
112
|
+
}
|
|
113
|
+
export type QrPollStatus = 'pending' | 'slow_down' | 'expired' | 'denied'
|
|
114
|
+
/** Namespace mode: the binding stopped being active between approval and this poll. */
|
|
115
|
+
| 'unavailable'
|
|
116
|
+
/** The issuer's token response or the id token it carried was rejected -- `reason` is the same vocabulary `auth_error` uses. */
|
|
117
|
+
| 'failed' | 'signed_in';
|
|
118
|
+
export interface QrPollResult {
|
|
119
|
+
status: QrPollStatus;
|
|
120
|
+
/** Set only when `status` is `'failed'`. */
|
|
121
|
+
reason?: string;
|
|
122
|
+
cookies: SetCookie[];
|
|
123
|
+
}
|
|
83
124
|
export interface Auth {
|
|
84
125
|
readonly sessionCookieName: string;
|
|
85
126
|
/** GET {basePath}/start?next=&intent= : begins the OIDC flow; the issuer sends the browser to {basePath}/login?authRequest= */
|
|
@@ -199,6 +240,48 @@ export interface Auth {
|
|
|
199
240
|
signUp(input: {
|
|
200
241
|
authRequestId: string;
|
|
201
242
|
} & NewUser): Promise<SignUpResult>;
|
|
243
|
+
/**
|
|
244
|
+
* Starts registering a passkey on this app's own domain, for whoever is
|
|
245
|
+
* signed in on `request` -- never for a stranger. Throws `AuthError` when
|
|
246
|
+
* nobody is: `account_changed`/`unavailable` exactly as `read` reports
|
|
247
|
+
* them, `unauthenticated` otherwise.
|
|
248
|
+
*/
|
|
249
|
+
startPasskeyRegistration(request: Request): Promise<PasskeyRegistrationStart>;
|
|
250
|
+
/** The second half of `startPasskeyRegistration`. Same signed-in requirement. */
|
|
251
|
+
finishPasskeyRegistration(request: Request, input: {
|
|
252
|
+
passkeyId: string;
|
|
253
|
+
credential: unknown;
|
|
254
|
+
name: string;
|
|
255
|
+
}): Promise<PasskeyRegistrationResult>;
|
|
256
|
+
/** The passkey half of `signIn`: a challenge to answer with `@wtfalch/auth/passkey`'s `getPasskey`. */
|
|
257
|
+
startPasskeySignIn(input: {
|
|
258
|
+
authRequestId: string;
|
|
259
|
+
email: string;
|
|
260
|
+
}): Promise<PasskeySignInStart>;
|
|
261
|
+
/** The second half of a passkey sign-in -- otherwise exactly `signIn`: same redirect and hosted-login handling, same error mapping. */
|
|
262
|
+
finishPasskeySignIn(input: {
|
|
263
|
+
authRequestId: string;
|
|
264
|
+
sessionId: string;
|
|
265
|
+
credential: unknown;
|
|
266
|
+
}): Promise<SignInResult>;
|
|
267
|
+
/**
|
|
268
|
+
* Starts a QR sign-in: the OAuth device authorization grant, on this app's
|
|
269
|
+
* own web client. `url` is what the QR code encodes (and `userCode` what a
|
|
270
|
+
* person types instead); a phone already signed in approves it, and
|
|
271
|
+
* `pollQrSignIn` is how this device finds out. In namespace mode, throws
|
|
272
|
+
* `AuthError('unavailable', ...)` when the binding is not active -- unlike
|
|
273
|
+
* `start`, which answers a 503 `Response` for the same case rather than
|
|
274
|
+
* throwing.
|
|
275
|
+
*/
|
|
276
|
+
startQrSignIn(): Promise<QrSignInStart>;
|
|
277
|
+
/**
|
|
278
|
+
* Polls the transaction `startQrSignIn` began; `request` carries its
|
|
279
|
+
* cookie. Never throws for an outcome of the poll itself -- a suspended
|
|
280
|
+
* binding or a rejected token response answers `'unavailable'`/`'failed'`
|
|
281
|
+
* with the transaction cookie cleared, the same way `complete` answers a
|
|
282
|
+
* failed callback rather than throwing.
|
|
283
|
+
*/
|
|
284
|
+
pollQrSignIn(request: Request): Promise<QrPollResult>;
|
|
202
285
|
}
|
|
203
286
|
export declare function createAuth(input: AuthOptions): Auth;
|
|
204
287
|
export type { AuthRequest, NewUser, SetCookie };
|
package/dist/auth.js
CHANGED
|
@@ -2,9 +2,10 @@ import { errors } from 'jose';
|
|
|
2
2
|
import * as client from 'openid-client';
|
|
3
3
|
import { Broker, BrokerError } from './broker.js';
|
|
4
4
|
import { resolveOptions } from './config.js';
|
|
5
|
-
import { clearSession, clearTransaction, cookieFrom, openSession, openTransaction, sealSession, sealTransaction, sessionCookieName, transactionCookieName, } from './cookies.js';
|
|
5
|
+
import { clearQrTransaction, clearSession, clearTransaction, cookieFrom, openQrTransaction, openSession, openTransaction, qrTransactionCookieName, sealQrTransaction, sealSession, sealTransaction, sessionCookieName, transactionCookieName, } from './cookies.js';
|
|
6
6
|
import { namespaceSessions } from './namespace-session.js';
|
|
7
|
-
import { AuthError, ORG_CLAIM, Oidc } from './oidc.js';
|
|
7
|
+
import { AuthError, ORG_CLAIM, Oidc, scopeFor } from './oidc.js';
|
|
8
|
+
import { pollDeviceToken, requestDeviceAuthorization } from './qr.js';
|
|
8
9
|
import { safeNextPath } from './redirect.js';
|
|
9
10
|
export function createAuth(input) {
|
|
10
11
|
// Resolved on first use so `next build`, which imports every route module, needs none of the values set.
|
|
@@ -59,6 +60,19 @@ export function createAuth(input) {
|
|
|
59
60
|
const sessionCookie = (tokens) => options().namespace
|
|
60
61
|
? sessions.create(tokens)
|
|
61
62
|
: sealSession(options(), { idt: tokens.idToken, rt: tokens.refreshToken });
|
|
63
|
+
/**
|
|
64
|
+
* Signing in again replaces this binding's previous session outright: in
|
|
65
|
+
* namespace mode, ends whatever session record `cookieHeader` already
|
|
66
|
+
* named. Shared by `complete` and `pollQrSignIn`, the two places a fresh
|
|
67
|
+
* session is created for a browser that may already hold one.
|
|
68
|
+
*/
|
|
69
|
+
const replacePreviousSession = async (cookieHeader) => {
|
|
70
|
+
if (!options().namespace)
|
|
71
|
+
return;
|
|
72
|
+
await sessions
|
|
73
|
+
.end(cookieFrom(cookieHeader, sessionCookieName(options())))
|
|
74
|
+
.catch((error) => console.error('@wtfalch/auth: could not end the replaced session', error));
|
|
75
|
+
};
|
|
62
76
|
const unavailable = () => new Response('Sign-in is unavailable for this workspace', { status: 503 });
|
|
63
77
|
const startUrl = (next, intent = 'login') => {
|
|
64
78
|
const url = new URL(`${options().basePath}/start`, options().appUrl);
|
|
@@ -114,11 +128,7 @@ export function createAuth(input) {
|
|
|
114
128
|
catch (error) {
|
|
115
129
|
return failure(reasonOf(error), [cleared]);
|
|
116
130
|
}
|
|
117
|
-
|
|
118
|
-
if (options().namespace)
|
|
119
|
-
await sessions
|
|
120
|
-
.end(cookieFrom(cookieHeader, sessionCookieName(options())))
|
|
121
|
-
.catch((error) => console.error('@wtfalch/auth: could not end the replaced session', error));
|
|
131
|
+
await replacePreviousSession(cookieHeader);
|
|
122
132
|
return {
|
|
123
133
|
location: new URL(transaction.nx, options().appUrl).href,
|
|
124
134
|
cookies: [cleared, session],
|
|
@@ -256,6 +266,22 @@ export function createAuth(input) {
|
|
|
256
266
|
return { kind: 'deny', cookies };
|
|
257
267
|
return { kind: 'redirect', location: startUrl(`${url.pathname}${url.search}`), cookies };
|
|
258
268
|
};
|
|
269
|
+
/**
|
|
270
|
+
* The person signed in on `request`, for the passkey routes -- never a
|
|
271
|
+
* stranger. Adapts `requireUser`'s reasons for a caller that throws rather
|
|
272
|
+
* than redirects: `account_changed`/`unavailable` are read's own, and
|
|
273
|
+
* plain absence becomes `unauthenticated`, which read has no reason for.
|
|
274
|
+
*/
|
|
275
|
+
const currentUser = async (request) => {
|
|
276
|
+
const { user, accountChanged, unavailable } = await read(request, { refresh: false });
|
|
277
|
+
if (accountChanged)
|
|
278
|
+
throw new AuthError('account_changed', 'Choose an account to continue');
|
|
279
|
+
if (unavailable)
|
|
280
|
+
throw new AuthError('unavailable', 'Sign-in is unavailable for this workspace');
|
|
281
|
+
if (!user)
|
|
282
|
+
throw new AuthError('unauthenticated', 'sign in to use a passkey');
|
|
283
|
+
return user;
|
|
284
|
+
};
|
|
259
285
|
const people = (options) => broker().people(options);
|
|
260
286
|
const setPersonActive = async (userId, active) => {
|
|
261
287
|
const result = await broker().setPersonActive(userId, active);
|
|
@@ -417,6 +443,114 @@ export function createAuth(input) {
|
|
|
417
443
|
return { ok: false, error: 'unavailable', message: 'the sign-in service did not answer' };
|
|
418
444
|
}
|
|
419
445
|
};
|
|
446
|
+
const startPasskeyRegistration = async (request) => {
|
|
447
|
+
const user = await currentUser(request);
|
|
448
|
+
return broker().startPasskeyRegistration(user.id);
|
|
449
|
+
};
|
|
450
|
+
const finishPasskeyRegistration = async (request, input) => {
|
|
451
|
+
const user = await currentUser(request);
|
|
452
|
+
// Built from named fields, never spread: `input` crosses a trust
|
|
453
|
+
// boundary (a Next.js Server Action does not enforce its TS parameter
|
|
454
|
+
// types on the wire), and a spread after `userId: user.id` would let a
|
|
455
|
+
// caller-supplied `userId` key overwrite the session's own identity.
|
|
456
|
+
const passkeyId = input?.passkeyId;
|
|
457
|
+
const name = typeof input?.name === 'string' ? input.name.trim() : '';
|
|
458
|
+
const credential = input?.credential;
|
|
459
|
+
if (typeof passkeyId !== 'string' ||
|
|
460
|
+
passkeyId.length === 0 ||
|
|
461
|
+
name.length === 0 ||
|
|
462
|
+
name.length > 64 ||
|
|
463
|
+
!credential ||
|
|
464
|
+
typeof credential !== 'object' ||
|
|
465
|
+
Array.isArray(credential)) {
|
|
466
|
+
return { ok: false, error: 'invalid' };
|
|
467
|
+
}
|
|
468
|
+
try {
|
|
469
|
+
await broker().finishPasskeyRegistration({ userId: user.id, passkeyId, credential, name });
|
|
470
|
+
return { ok: true };
|
|
471
|
+
}
|
|
472
|
+
catch (error) {
|
|
473
|
+
if (error instanceof BrokerError && error.status < 500)
|
|
474
|
+
return { ok: false, error: 'invalid' };
|
|
475
|
+
throw error;
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
const startPasskeySignIn = async ({ authRequestId, email, }) => {
|
|
479
|
+
try {
|
|
480
|
+
const { sessionId, options } = await broker().startPasskeySignIn({ authRequestId, email });
|
|
481
|
+
return { ok: true, sessionId, options };
|
|
482
|
+
}
|
|
483
|
+
catch (error) {
|
|
484
|
+
return { ok: false, error: signInError(error) };
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
const finishPasskeySignIn = async ({ authRequestId, sessionId, credential, }) => {
|
|
488
|
+
try {
|
|
489
|
+
return {
|
|
490
|
+
ok: true,
|
|
491
|
+
redirectTo: await broker().finishPasskeySignIn({ authRequestId, sessionId, credential }),
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
catch (error) {
|
|
495
|
+
if (needsHostedLogin(error))
|
|
496
|
+
return { ok: true, redirectTo: hostedUrl(authRequestId), hosted: true };
|
|
497
|
+
return { ok: false, error: signInError(error) };
|
|
498
|
+
}
|
|
499
|
+
};
|
|
500
|
+
const startQrSignIn = async () => {
|
|
501
|
+
if (options().namespace && !(await sessions.bindingActive()))
|
|
502
|
+
throw new AuthError('unavailable', 'the namespace binding is not active');
|
|
503
|
+
const device = await requestDeviceAuthorization(options(), scopeFor(options().organizationId));
|
|
504
|
+
const cookie = await sealQrTransaction(options(), { dc: device.deviceCode, iv: device.interval }, device.expiresIn);
|
|
505
|
+
return {
|
|
506
|
+
url: device.verificationUriComplete,
|
|
507
|
+
userCode: device.userCode,
|
|
508
|
+
expiresIn: device.expiresIn,
|
|
509
|
+
interval: device.interval,
|
|
510
|
+
cookies: [cookie],
|
|
511
|
+
};
|
|
512
|
+
};
|
|
513
|
+
const pollQrSignIn = async (request) => {
|
|
514
|
+
const cleared = clearQrTransaction(options());
|
|
515
|
+
const transaction = await openQrTransaction(options(), cookieFrom(request.headers.get('cookie'), qrTransactionCookieName(options())));
|
|
516
|
+
// Missing, tampered, another binding's, or its own `exp` (the issuer's
|
|
517
|
+
// `expires_in`, sealed in by `startQrSignIn`) already past: all the same
|
|
518
|
+
// refusal a stranger's cookie gets everywhere else in this file.
|
|
519
|
+
if (!transaction)
|
|
520
|
+
return { status: 'expired', cookies: [cleared] };
|
|
521
|
+
// An outcome of the poll itself -- a rejected token response, or an id
|
|
522
|
+
// token that fails validation (wrong organisation, bad signature) --
|
|
523
|
+
// answers rather than throws, exactly as `complete` answers a failed
|
|
524
|
+
// exchange with `failure(reasonOf(error), ...)` instead of throwing it.
|
|
525
|
+
let outcome;
|
|
526
|
+
try {
|
|
527
|
+
outcome = await pollDeviceToken(options(), oidc(), transaction.dc);
|
|
528
|
+
}
|
|
529
|
+
catch (error) {
|
|
530
|
+
return { status: 'failed', reason: reasonOf(error), cookies: [cleared] };
|
|
531
|
+
}
|
|
532
|
+
if (outcome.status === 'pending')
|
|
533
|
+
return { status: 'pending', cookies: [] };
|
|
534
|
+
if (outcome.status === 'slow_down')
|
|
535
|
+
return { status: 'slow_down', cookies: [] };
|
|
536
|
+
if (outcome.status === 'expired')
|
|
537
|
+
return { status: 'expired', cookies: [cleared] };
|
|
538
|
+
if (outcome.status === 'denied')
|
|
539
|
+
return { status: 'denied', cookies: [cleared] };
|
|
540
|
+
// The binding can go suspended between approval and this poll landing;
|
|
541
|
+
// that is this outcome, not a programmer error, so it answers too.
|
|
542
|
+
if (options().namespace && !(await sessions.bindingActive()))
|
|
543
|
+
return { status: 'unavailable', cookies: [cleared] };
|
|
544
|
+
let session;
|
|
545
|
+
try {
|
|
546
|
+
session = await sessionCookie(outcome.tokens);
|
|
547
|
+
}
|
|
548
|
+
catch (error) {
|
|
549
|
+
return { status: 'failed', reason: reasonOf(error), cookies: [cleared] };
|
|
550
|
+
}
|
|
551
|
+
await replacePreviousSession(request.headers.get('cookie'));
|
|
552
|
+
return { status: 'signed_in', cookies: [cleared, session] };
|
|
553
|
+
};
|
|
420
554
|
const failure = (reason, cookies) => {
|
|
421
555
|
const url = new URL(options().onError, options().appUrl);
|
|
422
556
|
url.searchParams.set('auth_error', reason);
|
|
@@ -456,6 +590,12 @@ export function createAuth(input) {
|
|
|
456
590
|
authRequest,
|
|
457
591
|
signIn,
|
|
458
592
|
signUp,
|
|
593
|
+
startPasskeyRegistration,
|
|
594
|
+
finishPasskeyRegistration,
|
|
595
|
+
startPasskeySignIn,
|
|
596
|
+
finishPasskeySignIn,
|
|
597
|
+
startQrSignIn,
|
|
598
|
+
pollQrSignIn,
|
|
459
599
|
};
|
|
460
600
|
}
|
|
461
601
|
function redirect(location, cookies, status = 302) {
|
package/dist/broker.d.ts
CHANGED
|
@@ -63,6 +63,30 @@ export declare class Broker {
|
|
|
63
63
|
signUp(input: {
|
|
64
64
|
authRequestId: string;
|
|
65
65
|
} & NewUser): Promise<string>;
|
|
66
|
+
/** A passkey for somebody already signed in on the app's own domain. */
|
|
67
|
+
startPasskeyRegistration(userId: string): Promise<{
|
|
68
|
+
passkeyId: string;
|
|
69
|
+
options: unknown;
|
|
70
|
+
}>;
|
|
71
|
+
finishPasskeyRegistration(input: {
|
|
72
|
+
userId: string;
|
|
73
|
+
passkeyId: string;
|
|
74
|
+
credential: unknown;
|
|
75
|
+
name: string;
|
|
76
|
+
}): Promise<void>;
|
|
77
|
+
/** The passkey half of `signIn`: a challenge to answer, not yet a session. */
|
|
78
|
+
startPasskeySignIn(input: {
|
|
79
|
+
authRequestId: string;
|
|
80
|
+
email: string;
|
|
81
|
+
}): Promise<{
|
|
82
|
+
sessionId: string;
|
|
83
|
+
options: unknown;
|
|
84
|
+
}>;
|
|
85
|
+
finishPasskeySignIn(input: {
|
|
86
|
+
authRequestId: string;
|
|
87
|
+
sessionId: string;
|
|
88
|
+
credential: unknown;
|
|
89
|
+
}): Promise<string>;
|
|
66
90
|
followLink(input: {
|
|
67
91
|
authRequestId: string;
|
|
68
92
|
sessionId: string;
|
|
@@ -89,11 +113,14 @@ export declare class Broker {
|
|
|
89
113
|
/**
|
|
90
114
|
* What the service routes this key to. A suspended binding's key is refused.
|
|
91
115
|
* Bounded, because every namespace request waits on this answer when it is due.
|
|
116
|
+
* `revoked` names the organization's recently deactivated people. It is absent
|
|
117
|
+
* from an older service, or while the service cannot ask the issuer.
|
|
92
118
|
*/
|
|
93
119
|
binding(): Promise<{
|
|
94
120
|
organizationId: string;
|
|
95
121
|
clientIds: string[];
|
|
96
122
|
origins: string[];
|
|
123
|
+
revoked?: string[];
|
|
97
124
|
}>;
|
|
98
125
|
verifyEmail(userId: string, code: string): Promise<unknown>;
|
|
99
126
|
/** A public client revoking its own token: no key, no service. */
|
package/dist/broker.js
CHANGED
|
@@ -76,6 +76,21 @@ export class Broker {
|
|
|
76
76
|
});
|
|
77
77
|
return callbackUrl;
|
|
78
78
|
}
|
|
79
|
+
/** A passkey for somebody already signed in on the app's own domain. */
|
|
80
|
+
startPasskeyRegistration(userId) {
|
|
81
|
+
return this.call('POST', '/passkey/register/start', { userId });
|
|
82
|
+
}
|
|
83
|
+
async finishPasskeyRegistration(input) {
|
|
84
|
+
await this.call('POST', '/passkey/register/finish', input);
|
|
85
|
+
}
|
|
86
|
+
/** The passkey half of `signIn`: a challenge to answer, not yet a session. */
|
|
87
|
+
startPasskeySignIn(input) {
|
|
88
|
+
return this.call('POST', '/passkey/start', input);
|
|
89
|
+
}
|
|
90
|
+
async finishPasskeySignIn(input) {
|
|
91
|
+
const { callbackUrl } = await this.call('POST', '/passkey/finish', input);
|
|
92
|
+
return callbackUrl;
|
|
93
|
+
}
|
|
79
94
|
async followLink(input) {
|
|
80
95
|
const { callbackUrl } = await this.call('POST', '/link/follow', input);
|
|
81
96
|
return callbackUrl;
|
|
@@ -99,6 +114,8 @@ export class Broker {
|
|
|
99
114
|
/**
|
|
100
115
|
* What the service routes this key to. A suspended binding's key is refused.
|
|
101
116
|
* Bounded, because every namespace request waits on this answer when it is due.
|
|
117
|
+
* `revoked` names the organization's recently deactivated people. It is absent
|
|
118
|
+
* from an older service, or while the service cannot ask the issuer.
|
|
102
119
|
*/
|
|
103
120
|
binding() {
|
|
104
121
|
return this.call('GET', '/binding', {}, AbortSignal.timeout(5_000));
|
package/dist/cookies.d.ts
CHANGED
|
@@ -28,6 +28,11 @@ export interface LinkPayload extends JWTPayload {
|
|
|
28
28
|
stk: string;
|
|
29
29
|
ar: string;
|
|
30
30
|
}
|
|
31
|
+
/** The QR device-grant transaction: the device_code, never handed to the caller, and the interval the issuer asked us to poll at. Its own `exp` -- set to the issuer's `expires_in` -- is what makes an unsealed-past-expiry cookie fail open with the others below. */
|
|
32
|
+
export interface QrTransactionPayload extends JWTPayload {
|
|
33
|
+
dc: string;
|
|
34
|
+
iv: number;
|
|
35
|
+
}
|
|
31
36
|
export interface SetCookie {
|
|
32
37
|
name: string;
|
|
33
38
|
value: string;
|
|
@@ -52,6 +57,15 @@ export declare function digest(value: string): Promise<string>;
|
|
|
52
57
|
export declare function sealTransaction(options: ResolvedOptions, payload: Omit<TransactionPayload, keyof JWTPayload>): Promise<SetCookie>;
|
|
53
58
|
export declare function openTransaction(options: ResolvedOptions, value: string | undefined): Promise<TransactionPayload | null>;
|
|
54
59
|
export declare function clearTransaction(options: ResolvedOptions): SetCookie;
|
|
60
|
+
export declare function qrTransactionCookieName(options: ResolvedOptions): string;
|
|
61
|
+
/**
|
|
62
|
+
* `ttl` is the issuer's own `expires_in` for the device code, not a fixed
|
|
63
|
+
* constant like the other transactions: the cookie must not outlive what it
|
|
64
|
+
* points at.
|
|
65
|
+
*/
|
|
66
|
+
export declare function sealQrTransaction(options: ResolvedOptions, payload: Omit<QrTransactionPayload, keyof JWTPayload>, ttl: number): Promise<SetCookie>;
|
|
67
|
+
export declare function openQrTransaction(options: ResolvedOptions, value: string | undefined): Promise<QrTransactionPayload | null>;
|
|
68
|
+
export declare function clearQrTransaction(options: ResolvedOptions): SetCookie;
|
|
55
69
|
export declare function linkCookieName(options: ResolvedOptions): string;
|
|
56
70
|
export declare function sealLink(options: ResolvedOptions, payload: Omit<LinkPayload, keyof JWTPayload>): Promise<SetCookie>;
|
|
57
71
|
export declare function openLink(options: ResolvedOptions, value: string | undefined): Promise<LinkPayload | null>;
|
package/dist/cookies.js
CHANGED
|
@@ -3,6 +3,7 @@ import { AuthError } from './oidc.js';
|
|
|
3
3
|
const SESSION = 'wtfalch_auth';
|
|
4
4
|
const TRANSACTION = 'wtfalch_auth_tx';
|
|
5
5
|
const LINK = 'wtfalch_auth_link';
|
|
6
|
+
const QR = 'wtfalch_auth_qr';
|
|
6
7
|
const TRANSACTION_TTL = 10 * 60;
|
|
7
8
|
/**
|
|
8
9
|
* A namespace session handle lives as long as a browser will keep a cookie.
|
|
@@ -80,6 +81,27 @@ export async function openTransaction(options, value) {
|
|
|
80
81
|
export function clearTransaction(options) {
|
|
81
82
|
return setCookie(transactionCookieName(options), '', 0, options.secure);
|
|
82
83
|
}
|
|
84
|
+
export function qrTransactionCookieName(options) {
|
|
85
|
+
return options.secure ? `__Host-${QR}` : QR;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* `ttl` is the issuer's own `expires_in` for the device code, not a fixed
|
|
89
|
+
* constant like the other transactions: the cookie must not outlive what it
|
|
90
|
+
* points at.
|
|
91
|
+
*/
|
|
92
|
+
export async function sealQrTransaction(options, payload, ttl) {
|
|
93
|
+
const value = await seal(options, 'qr', payload, ttl);
|
|
94
|
+
return setCookie(qrTransactionCookieName(options), value, ttl, options.secure);
|
|
95
|
+
}
|
|
96
|
+
export async function openQrTransaction(options, value) {
|
|
97
|
+
const payload = await open(options, 'qr', value);
|
|
98
|
+
return payload && typeof payload.dc === 'string' && typeof payload.iv === 'number'
|
|
99
|
+
? payload
|
|
100
|
+
: null;
|
|
101
|
+
}
|
|
102
|
+
export function clearQrTransaction(options) {
|
|
103
|
+
return setCookie(qrTransactionCookieName(options), '', 0, options.secure);
|
|
104
|
+
}
|
|
83
105
|
export function linkCookieName(options) {
|
|
84
106
|
return options.secure ? `__Host-${LINK}` : LINK;
|
|
85
107
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { type Auth, type AuthRequest, type Gate, type GateRules, type Intent, type NewUser, type ReadResult, type ResetError, type SignInError, type SignInResult, type SignUpError, type SignUpResult, type SignedIn, type User, createAuth, } from './auth.js';
|
|
1
|
+
export { type Auth, type AuthRequest, type Gate, type GateRules, type Intent, type NewUser, type PasskeyRegistrationResult, type PasskeyRegistrationStart, type PasskeySignInStart, 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
2
|
export { type AuthOptions, ISSUER } from './config.js';
|
|
3
3
|
export type { SetCookie } from './cookies.js';
|
|
4
4
|
export type { SessionRecord, SessionStore, SessionUpdate } from './sessions.js';
|
package/dist/namespace-server.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jwtDecrypt } from 'jose';
|
|
2
2
|
import { resolveOptions } from './config.js';
|
|
3
3
|
import { cookieFrom, openTransaction, transactionCookieName } from './cookies.js';
|
|
4
|
-
import { parseNamespaceRegistry, } from './namespaces.js';
|
|
4
|
+
import { isWebService, parseNamespaceRegistry, } from './namespaces.js';
|
|
5
5
|
/** Resolve a shared callback from its authenticated transaction, never a URL
|
|
6
6
|
* namespace hint. The second open checks the entire current registry binding. */
|
|
7
7
|
export async function resolveNamespaceCallback(input) {
|
|
@@ -9,12 +9,16 @@ export async function resolveNamespaceCallback(input) {
|
|
|
9
9
|
const registry = parseNamespaceRegistry(input.registry);
|
|
10
10
|
const callback = new URL(`${input.basePath ?? '/auth'}/callback`, input.appOrigin).href;
|
|
11
11
|
const candidates = registry.namespaces.filter((n) => n.status === 'active' &&
|
|
12
|
-
n.services
|
|
12
|
+
n.services
|
|
13
|
+
.filter(isWebService)
|
|
14
|
+
.some((s) => s.serviceId === input.serviceId &&
|
|
13
15
|
s.deploymentId === input.deploymentId &&
|
|
14
16
|
s.appOrigin === input.appOrigin &&
|
|
15
17
|
s.redirectUris.includes(callback)));
|
|
16
18
|
const optionsFor = (namespace) => {
|
|
17
|
-
const service = namespace.services
|
|
19
|
+
const service = namespace.services
|
|
20
|
+
.filter(isWebService)
|
|
21
|
+
.find((s) => s.serviceId === input.serviceId && s.deploymentId === input.deploymentId);
|
|
18
22
|
if (!service)
|
|
19
23
|
throw new Error('Missing service');
|
|
20
24
|
const selection = {
|
|
@@ -35,15 +35,40 @@ export function namespaceSessions(deps) {
|
|
|
35
35
|
let status = null;
|
|
36
36
|
let retryAt = 0;
|
|
37
37
|
let pending = null;
|
|
38
|
+
// People whose sessions this instance has already ended for the current
|
|
39
|
+
// deactivation. Someone who leaves the list, by reactivation or by ageing
|
|
40
|
+
// out of the service's window, is forgotten, so a second deactivation
|
|
41
|
+
// ends their sessions again.
|
|
42
|
+
const ended = new Set();
|
|
43
|
+
const endDeactivated = async (revoked) => {
|
|
44
|
+
if (!Array.isArray(revoked))
|
|
45
|
+
return;
|
|
46
|
+
const current = new Set(revoked.filter((s) => typeof s === 'string' && !!s));
|
|
47
|
+
for (const subject of ended)
|
|
48
|
+
if (!current.has(subject))
|
|
49
|
+
ended.delete(subject);
|
|
50
|
+
for (const subject of current) {
|
|
51
|
+
if (ended.has(subject))
|
|
52
|
+
continue;
|
|
53
|
+
await revokeSubject(subject);
|
|
54
|
+
ended.add(subject);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
38
57
|
const askService = async () => {
|
|
39
58
|
const namespace = options().namespace;
|
|
40
59
|
if (!namespace)
|
|
41
60
|
return false;
|
|
42
61
|
try {
|
|
43
62
|
const routed = await deps.broker().binding();
|
|
44
|
-
|
|
63
|
+
const active = routed.organizationId === namespace.organizationId &&
|
|
45
64
|
routed.clientIds.includes(namespace.clientId) &&
|
|
46
|
-
routed.origins.includes(namespace.appOrigin)
|
|
65
|
+
routed.origins.includes(namespace.appOrigin);
|
|
66
|
+
// Deactivated anywhere, by any service or in the issuer's console: their
|
|
67
|
+
// sessions here end before this answer is used, so within the same bound
|
|
68
|
+
// as a suspension. Only a list about this binding's own organization counts.
|
|
69
|
+
if (routed.organizationId === namespace.organizationId)
|
|
70
|
+
await endDeactivated(routed.revoked);
|
|
71
|
+
return active;
|
|
47
72
|
}
|
|
48
73
|
catch (error) {
|
|
49
74
|
// Suspension removes the binding's key, so the service no longer knows it.
|