@wtfalch/auth 0.7.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 +24 -0
- package/dist/broker.js +15 -0
- package/dist/cookies.d.ts +14 -0
- package/dist/cookies.js +22 -0
- package/dist/index.d.ts +1 -1
- package/dist/namespaces.d.ts +39 -0
- package/dist/namespaces.js +14 -1
- 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 +5 -1
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;
|
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;
|
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/namespaces.d.ts
CHANGED
|
@@ -20,6 +20,19 @@ export interface NamespaceService {
|
|
|
20
20
|
postLogoutRedirectUris: string[];
|
|
21
21
|
credentialRef: string;
|
|
22
22
|
registration: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* A sign-in link may finish a sign-in on its own, making the inbox proof
|
|
25
|
+
* enough. The broker's `App.emailLink` holds the reasoning. Omitted means
|
|
26
|
+
* off.
|
|
27
|
+
*/
|
|
28
|
+
emailLink?: true;
|
|
29
|
+
/**
|
|
30
|
+
* A new device can be signed in by a QR code that an already signed-in
|
|
31
|
+
* person approves, via the issuer's OAuth device authorization grant on
|
|
32
|
+
* this binding's own OIDC client. Anyone can start one, so the approving
|
|
33
|
+
* page must show what is being approved. Omitted means off.
|
|
34
|
+
*/
|
|
35
|
+
qrSignIn?: true;
|
|
23
36
|
}
|
|
24
37
|
/**
|
|
25
38
|
* Installed software a person signs in to by approving on another device: a
|
|
@@ -108,6 +121,19 @@ export declare function resolveNamespaceBinding(registry: NamespaceRegistry, inp
|
|
|
108
121
|
postLogoutRedirectUris: string[];
|
|
109
122
|
credentialRef: string;
|
|
110
123
|
registration: boolean;
|
|
124
|
+
/**
|
|
125
|
+
* A sign-in link may finish a sign-in on its own, making the inbox proof
|
|
126
|
+
* enough. The broker's `App.emailLink` holds the reasoning. Omitted means
|
|
127
|
+
* off.
|
|
128
|
+
*/
|
|
129
|
+
emailLink?: true;
|
|
130
|
+
/**
|
|
131
|
+
* A new device can be signed in by a QR code that an already signed-in
|
|
132
|
+
* person approves, via the issuer's OAuth device authorization grant on
|
|
133
|
+
* this binding's own OIDC client. Anyone can start one, so the approving
|
|
134
|
+
* page must show what is being approved. Omitted means off.
|
|
135
|
+
*/
|
|
136
|
+
qrSignIn?: true;
|
|
111
137
|
appId: string;
|
|
112
138
|
clientId: string;
|
|
113
139
|
issuer: string;
|
|
@@ -143,6 +169,19 @@ export declare function resolveNamespaceContext(selection: NamespaceSelection, o
|
|
|
143
169
|
postLogoutRedirectUris: string[];
|
|
144
170
|
credentialRef: string;
|
|
145
171
|
registration: boolean;
|
|
172
|
+
/**
|
|
173
|
+
* A sign-in link may finish a sign-in on its own, making the inbox proof
|
|
174
|
+
* enough. The broker's `App.emailLink` holds the reasoning. Omitted means
|
|
175
|
+
* off.
|
|
176
|
+
*/
|
|
177
|
+
emailLink?: true;
|
|
178
|
+
/**
|
|
179
|
+
* A new device can be signed in by a QR code that an already signed-in
|
|
180
|
+
* person approves, via the issuer's OAuth device authorization grant on
|
|
181
|
+
* this binding's own OIDC client. Anyone can start one, so the approving
|
|
182
|
+
* page must show what is being approved. Omitted means off.
|
|
183
|
+
*/
|
|
184
|
+
qrSignIn?: true;
|
|
146
185
|
appId: string;
|
|
147
186
|
clientId: string;
|
|
148
187
|
issuer: string;
|
package/dist/namespaces.js
CHANGED
|
@@ -86,7 +86,14 @@ function credential(value) {
|
|
|
86
86
|
invalid('credentialRef');
|
|
87
87
|
return credentialRef;
|
|
88
88
|
}
|
|
89
|
-
const WEB_FIELDS = [
|
|
89
|
+
const WEB_FIELDS = [
|
|
90
|
+
'appOrigin',
|
|
91
|
+
'redirectUris',
|
|
92
|
+
'postLogoutRedirectUris',
|
|
93
|
+
'registration',
|
|
94
|
+
'emailLink',
|
|
95
|
+
'qrSignIn',
|
|
96
|
+
];
|
|
90
97
|
function service(value) {
|
|
91
98
|
const s = object(value, 'service');
|
|
92
99
|
const kind = s.kind === undefined ? 'web' : oneOf(s.kind, ['web', 'native', 'api'], 'kind');
|
|
@@ -111,6 +118,10 @@ function service(value) {
|
|
|
111
118
|
const credentialRef = credential(s.credentialRef);
|
|
112
119
|
if (s.registration !== undefined && typeof s.registration !== 'boolean')
|
|
113
120
|
invalid('registration');
|
|
121
|
+
if (s.emailLink !== undefined && typeof s.emailLink !== 'boolean')
|
|
122
|
+
invalid('emailLink');
|
|
123
|
+
if (s.qrSignIn !== undefined && typeof s.qrSignIn !== 'boolean')
|
|
124
|
+
invalid('qrSignIn');
|
|
114
125
|
return {
|
|
115
126
|
...identity,
|
|
116
127
|
appOrigin,
|
|
@@ -118,6 +129,8 @@ function service(value) {
|
|
|
118
129
|
postLogoutRedirectUris: uris(s.postLogoutRedirectUris, appOrigin, 'postLogoutRedirectUris'),
|
|
119
130
|
credentialRef,
|
|
120
131
|
registration: s.registration === true,
|
|
132
|
+
...(s.emailLink === true ? { emailLink: true } : {}),
|
|
133
|
+
...(s.qrSignIn === true ? { qrSignIn: true } : {}),
|
|
121
134
|
};
|
|
122
135
|
}
|
|
123
136
|
function parse(raw, resolved) {
|
package/dist/next.d.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { type NextRequest, NextResponse } from 'next/server';
|
|
2
|
-
import { type Auth, type AuthRequest, type GateRules, type Intent, type NewUser, type ResetError, type SignInError, type SignUpError, type User } from './auth.js';
|
|
2
|
+
import { type Auth, type AuthRequest, type GateRules, type Intent, type NewUser, type PasskeyRegistrationResult, type PasskeyRegistrationStart, type PasskeySignInStart, type QrPollStatus, type ResetError, type SignInError, type SignUpError, type User } from './auth.js';
|
|
3
3
|
import type { AuthOptions } from './config.js';
|
|
4
4
|
type SearchParams = Record<string, string | string[] | undefined>;
|
|
5
|
+
export interface QrSignIn {
|
|
6
|
+
url: string;
|
|
7
|
+
userCode: string;
|
|
8
|
+
expiresIn: number;
|
|
9
|
+
interval: number;
|
|
10
|
+
}
|
|
5
11
|
export interface NextAuth {
|
|
6
12
|
auth: Auth;
|
|
7
13
|
/** `export const { GET, POST } = handlers` from app/auth/[...auth]/route.ts. */
|
|
@@ -67,6 +73,47 @@ export interface NextAuth {
|
|
|
67
73
|
error: ResetError;
|
|
68
74
|
message: string;
|
|
69
75
|
}>;
|
|
76
|
+
/**
|
|
77
|
+
* Registers a passkey on this app's own domain for the person signed in
|
|
78
|
+
* here. Throws `AuthError('unauthenticated', ...)` for nobody, exactly as
|
|
79
|
+
* `requireUser` throws `account_changed`/`unavailable` for those.
|
|
80
|
+
*/
|
|
81
|
+
startPasskeyRegistration: () => Promise<PasskeyRegistrationStart>;
|
|
82
|
+
/** The second half of `startPasskeyRegistration`. Same signed-in requirement. */
|
|
83
|
+
finishPasskeyRegistration: (input: {
|
|
84
|
+
passkeyId: string;
|
|
85
|
+
credential: unknown;
|
|
86
|
+
name: string;
|
|
87
|
+
}) => Promise<PasskeyRegistrationResult>;
|
|
88
|
+
/** The passkey half of `signIn`: a challenge to answer with `@wtfalch/auth/passkey`'s `getPasskey`. */
|
|
89
|
+
startPasskeySignIn: (input: {
|
|
90
|
+
authRequestId: string;
|
|
91
|
+
email: string;
|
|
92
|
+
}) => Promise<PasskeySignInStart>;
|
|
93
|
+
/** For a server action: redirects into the app on success, returns the error otherwise -- the passkey half of `signIn`. */
|
|
94
|
+
finishPasskeySignIn: (input: {
|
|
95
|
+
authRequestId: string;
|
|
96
|
+
sessionId: string;
|
|
97
|
+
credential: unknown;
|
|
98
|
+
}) => Promise<{
|
|
99
|
+
error: SignInError;
|
|
100
|
+
}>;
|
|
101
|
+
/**
|
|
102
|
+
* Starts a QR sign-in from a server action or route handler, setting the
|
|
103
|
+
* transaction cookie. In namespace mode, throws `AuthError('unavailable', ...)`
|
|
104
|
+
* when the binding is not active -- unlike `proxy`/`gate`, which answer a
|
|
105
|
+
* 503 for the same case rather than throwing.
|
|
106
|
+
*/
|
|
107
|
+
startQrSignIn: () => Promise<QrSignIn>;
|
|
108
|
+
/**
|
|
109
|
+
* Polls the transaction `startQrSignIn` began, reading and setting cookies
|
|
110
|
+
* through `next/headers`. Never throws for an outcome of the poll itself --
|
|
111
|
+
* see `Auth['pollQrSignIn']`. `reason` is set only when `status` is `'failed'`.
|
|
112
|
+
*/
|
|
113
|
+
pollQrSignIn: () => Promise<{
|
|
114
|
+
status: QrPollStatus;
|
|
115
|
+
reason?: string;
|
|
116
|
+
}>;
|
|
70
117
|
}
|
|
71
118
|
export declare function nextAuth(options: AuthOptions): NextAuth;
|
|
72
|
-
export type { Auth, AuthOptions, AuthRequest, GateRules, Intent, NewUser, User };
|
|
119
|
+
export type { Auth, AuthOptions, AuthRequest, GateRules, Intent, NewUser, PasskeyRegistrationResult, PasskeyRegistrationStart, PasskeySignInStart, QrPollStatus, User, };
|
package/dist/next.js
CHANGED
|
@@ -119,6 +119,29 @@ export function nextAuth(options) {
|
|
|
119
119
|
const url = new URL(location);
|
|
120
120
|
redirect(`${url.pathname}${url.search}`);
|
|
121
121
|
};
|
|
122
|
+
// The passkey routes take a `Request`; a server action has none of its
|
|
123
|
+
// own, only what `cookies()` already exposes for this request. Only the
|
|
124
|
+
// cookie header matters to `read`, so a synthetic request carrying just
|
|
125
|
+
// that is exactly as good as the real one.
|
|
126
|
+
const requestFromCookies = async () => {
|
|
127
|
+
const store = await cookies();
|
|
128
|
+
const header = store
|
|
129
|
+
.getAll()
|
|
130
|
+
.map((c) => `${c.name}=${c.value}`)
|
|
131
|
+
.join('; ');
|
|
132
|
+
return new Request('http://localhost/', header ? { headers: { cookie: header } } : undefined);
|
|
133
|
+
};
|
|
134
|
+
const startPasskeyRegistration = async () => auth.startPasskeyRegistration(await requestFromCookies());
|
|
135
|
+
const finishPasskeyRegistration = async (input) => auth.finishPasskeyRegistration(await requestFromCookies(), input);
|
|
136
|
+
const finishPasskeySignIn = async (input) => {
|
|
137
|
+
const result = await auth.finishPasskeySignIn(input);
|
|
138
|
+
if (result.ok) {
|
|
139
|
+
if (result.hosted)
|
|
140
|
+
redirect(result.redirectTo);
|
|
141
|
+
return finish(result.redirectTo);
|
|
142
|
+
}
|
|
143
|
+
return { error: result.error };
|
|
144
|
+
};
|
|
122
145
|
const resetPassword = async (input) => {
|
|
123
146
|
const result = await auth.resetPassword(input);
|
|
124
147
|
if (!result.ok)
|
|
@@ -153,6 +176,28 @@ export function nextAuth(options) {
|
|
|
153
176
|
}
|
|
154
177
|
return { error: result.error, message: result.message };
|
|
155
178
|
};
|
|
179
|
+
const startQrSignIn = async () => {
|
|
180
|
+
const { cookies: set, ...start } = await auth.startQrSignIn();
|
|
181
|
+
const store = await cookies();
|
|
182
|
+
for (const c of set) {
|
|
183
|
+
if (c.value)
|
|
184
|
+
store.set(c.name, c.value, c.attributes);
|
|
185
|
+
else
|
|
186
|
+
store.delete({ name: c.name, path: '/' });
|
|
187
|
+
}
|
|
188
|
+
return start;
|
|
189
|
+
};
|
|
190
|
+
const pollQrSignIn = async () => {
|
|
191
|
+
const { status, reason, cookies: set } = await auth.pollQrSignIn(await requestFromCookies());
|
|
192
|
+
const store = await cookies();
|
|
193
|
+
for (const c of set) {
|
|
194
|
+
if (c.value)
|
|
195
|
+
store.set(c.name, c.value, c.attributes);
|
|
196
|
+
else
|
|
197
|
+
store.delete({ name: c.name, path: '/' });
|
|
198
|
+
}
|
|
199
|
+
return reason ? { status, reason } : { status };
|
|
200
|
+
};
|
|
156
201
|
return {
|
|
157
202
|
auth,
|
|
158
203
|
handlers: { GET: auth.handle, POST: auth.handle },
|
|
@@ -169,5 +214,11 @@ export function nextAuth(options) {
|
|
|
169
214
|
people: auth.people,
|
|
170
215
|
setPersonActive: auth.setPersonActive,
|
|
171
216
|
resetPassword,
|
|
217
|
+
startPasskeyRegistration,
|
|
218
|
+
finishPasskeyRegistration,
|
|
219
|
+
startPasskeySignIn: auth.startPasskeySignIn,
|
|
220
|
+
finishPasskeySignIn,
|
|
221
|
+
startQrSignIn,
|
|
222
|
+
pollQrSignIn,
|
|
172
223
|
};
|
|
173
224
|
}
|
package/dist/oidc.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import * as jose from 'jose';
|
|
2
2
|
import type { ResolvedOptions } from './config.js';
|
|
3
3
|
export declare const ORG_CLAIM = "urn:zitadel:iam:user:resourceowner:id";
|
|
4
|
+
/** The scopes every flow asks for: the code-exchange authorization request and the QR device grant alike. */
|
|
5
|
+
export declare function scopeFor(organizationId: string): string;
|
|
4
6
|
export interface Tokens {
|
|
5
7
|
idToken: string;
|
|
6
8
|
refreshToken?: string;
|
|
@@ -28,7 +30,9 @@ export declare class Oidc {
|
|
|
28
30
|
verify(idToken: string): Promise<jose.JWTPayload>;
|
|
29
31
|
private assertOrganization;
|
|
30
32
|
}
|
|
31
|
-
export type AuthErrorReason = 'expired' | 'state' | 'denied' | 'exchange' | 'organization' | 'cookie' | 'request' | 'account_changed' | 'unavailable'
|
|
33
|
+
export type AuthErrorReason = 'expired' | 'state' | 'denied' | 'exchange' | 'organization' | 'cookie' | 'request' | 'account_changed' | 'unavailable'
|
|
34
|
+
/** No session at all -- distinct from `account_changed`/`unavailable`, which `requireUser` already covers by redirecting instead. */
|
|
35
|
+
| 'unauthenticated';
|
|
32
36
|
export declare class AuthError extends Error {
|
|
33
37
|
readonly reason: AuthErrorReason;
|
|
34
38
|
constructor(reason: AuthErrorReason, message: string);
|
package/dist/oidc.js
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import * as jose from 'jose';
|
|
2
2
|
import * as client from 'openid-client';
|
|
3
3
|
export const ORG_CLAIM = 'urn:zitadel:iam:user:resourceowner:id';
|
|
4
|
+
/** The scopes every flow asks for: the code-exchange authorization request and the QR device grant alike. */
|
|
5
|
+
export function scopeFor(organizationId) {
|
|
6
|
+
return [
|
|
7
|
+
'openid',
|
|
8
|
+
'email',
|
|
9
|
+
'profile',
|
|
10
|
+
'offline_access',
|
|
11
|
+
`urn:zitadel:iam:org:id:${organizationId}`,
|
|
12
|
+
'urn:zitadel:iam:user:resourceowner',
|
|
13
|
+
].join(' ');
|
|
14
|
+
}
|
|
4
15
|
export class Oidc {
|
|
5
16
|
options;
|
|
6
17
|
configuration = null;
|
|
@@ -46,14 +57,7 @@ export class Oidc {
|
|
|
46
57
|
return client.buildAuthorizationUrl(await this.config(), {
|
|
47
58
|
...(create ? { prompt: 'create' } : {}),
|
|
48
59
|
redirect_uri: this.redirectUri.href,
|
|
49
|
-
scope:
|
|
50
|
-
'openid',
|
|
51
|
-
'email',
|
|
52
|
-
'profile',
|
|
53
|
-
'offline_access',
|
|
54
|
-
`urn:zitadel:iam:org:id:${this.options.organizationId}`,
|
|
55
|
-
'urn:zitadel:iam:user:resourceowner',
|
|
56
|
-
].join(' '),
|
|
60
|
+
scope: scopeFor(this.options.organizationId),
|
|
57
61
|
state,
|
|
58
62
|
nonce,
|
|
59
63
|
code_challenge: await client.calculatePKCECodeChallenge(codeVerifier),
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The WebAuthn ceremony, for an app's own sign-in page: `createPasskey`
|
|
3
|
+
* answers `auth.startPasskeyRegistration`'s options, `getPasskey` answers
|
|
4
|
+
* `auth.startPasskeySignIn`'s. Both take the options JSON exactly as the
|
|
5
|
+
* broker hands it back -- ZITADEL's own `publicKeyCredentialCreationOptions`
|
|
6
|
+
* / `publicKeyCredentialRequestOptions`, base64url strings where the
|
|
7
|
+
* WebAuthn spec wants `ArrayBuffer`s and a `publicKey` wrapper around the
|
|
8
|
+
* rest -- and return a plain JSON-serialisable credential ready for
|
|
9
|
+
* `auth.finishPasskeyRegistration` / `auth.finishPasskeySignIn`.
|
|
10
|
+
*
|
|
11
|
+
* This is a browser file: no server import, nothing that would drag OIDC or
|
|
12
|
+
* cookie code into the client bundle. The base64url<->`ArrayBuffer`
|
|
13
|
+
* conversion and the credential's field names are copied from the vendored
|
|
14
|
+
* login app's own client components (`register-passkey.tsx`,
|
|
15
|
+
* `login-passkey.tsx`, `helpers/base64.ts`) -- ZITADEL is the judge of what
|
|
16
|
+
* it accepts, not the WebAuthn spec's own `toJSON`, and that app is what
|
|
17
|
+
* ZITADEL already accepts. Not a byte-for-byte port, though: a `null`
|
|
18
|
+
* `userHandle` (a non-discoverable credential need not carry one) is sent as
|
|
19
|
+
* `''` here (see `getPasskey` below), the same answer the login app's own
|
|
20
|
+
* `new Uint8Array(...)` pre-wrap already produces for the same input --
|
|
21
|
+
* `new Uint8Array(null)` is a zero-length view, not a throw -- but is not
|
|
22
|
+
* the byte a naive re-encoding of a `null` would produce.
|
|
23
|
+
*/
|
|
24
|
+
/** A record whose own shape nobody but the issuer needs to know; only the few fields below get touched. */
|
|
25
|
+
type Loose = Record<string, unknown>;
|
|
26
|
+
/** ZITADEL's `publicKeyCredentialCreationOptions`, wrapped in `publicKey` as the WebAuthn `CredentialCreationOptions` dictionary is. */
|
|
27
|
+
export interface PasskeyCreationOptions {
|
|
28
|
+
publicKey: Loose & {
|
|
29
|
+
challenge: unknown;
|
|
30
|
+
user: Loose & {
|
|
31
|
+
id: unknown;
|
|
32
|
+
};
|
|
33
|
+
excludeCredentials?: Array<Loose & {
|
|
34
|
+
id: unknown;
|
|
35
|
+
}>;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** ZITADEL's `publicKeyCredentialRequestOptions`, wrapped in `publicKey` as `CredentialRequestOptions` is. */
|
|
39
|
+
export interface PasskeyRequestOptions {
|
|
40
|
+
publicKey: Loose & {
|
|
41
|
+
challenge: unknown;
|
|
42
|
+
allowCredentials?: Array<Loose & {
|
|
43
|
+
id: unknown;
|
|
44
|
+
}>;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** What `verifyPasskeyRegistration` (ZITADEL, via the broker's `/passkey/register/finish`) accepts as `publicKeyCredential`. */
|
|
48
|
+
export interface PasskeyCreationCredential {
|
|
49
|
+
id: string;
|
|
50
|
+
rawId: string;
|
|
51
|
+
type: string;
|
|
52
|
+
response: {
|
|
53
|
+
attestationObject: string;
|
|
54
|
+
clientDataJSON: string;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** What `checkPasskey` (ZITADEL, via the broker's `/passkey/finish`) accepts as `credentialAssertionData`. */
|
|
58
|
+
export interface PasskeyAssertionCredential {
|
|
59
|
+
id: string;
|
|
60
|
+
rawId: string;
|
|
61
|
+
type: string;
|
|
62
|
+
response: {
|
|
63
|
+
authenticatorData: string;
|
|
64
|
+
clientDataJSON: string;
|
|
65
|
+
signature: string;
|
|
66
|
+
userHandle: string;
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Registers a passkey: decodes `options` (from `auth.startPasskeyRegistration`)
|
|
71
|
+
* into the `CredentialCreationOptions` the browser wants, calls
|
|
72
|
+
* `navigator.credentials.create`, and re-encodes the result for
|
|
73
|
+
* `auth.finishPasskeyRegistration`.
|
|
74
|
+
*/
|
|
75
|
+
export declare function createPasskey(options: PasskeyCreationOptions): Promise<PasskeyCreationCredential>;
|
|
76
|
+
/**
|
|
77
|
+
* Signs in with a passkey: decodes `options` (from `auth.startPasskeySignIn`)
|
|
78
|
+
* into the `CredentialRequestOptions` the browser wants, calls
|
|
79
|
+
* `navigator.credentials.get`, and re-encodes the result for
|
|
80
|
+
* `auth.finishPasskeySignIn`.
|
|
81
|
+
*/
|
|
82
|
+
export declare function getPasskey(options: PasskeyRequestOptions): Promise<PasskeyAssertionCredential>;
|
|
83
|
+
export {};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The WebAuthn ceremony, for an app's own sign-in page: `createPasskey`
|
|
3
|
+
* answers `auth.startPasskeyRegistration`'s options, `getPasskey` answers
|
|
4
|
+
* `auth.startPasskeySignIn`'s. Both take the options JSON exactly as the
|
|
5
|
+
* broker hands it back -- ZITADEL's own `publicKeyCredentialCreationOptions`
|
|
6
|
+
* / `publicKeyCredentialRequestOptions`, base64url strings where the
|
|
7
|
+
* WebAuthn spec wants `ArrayBuffer`s and a `publicKey` wrapper around the
|
|
8
|
+
* rest -- and return a plain JSON-serialisable credential ready for
|
|
9
|
+
* `auth.finishPasskeyRegistration` / `auth.finishPasskeySignIn`.
|
|
10
|
+
*
|
|
11
|
+
* This is a browser file: no server import, nothing that would drag OIDC or
|
|
12
|
+
* cookie code into the client bundle. The base64url<->`ArrayBuffer`
|
|
13
|
+
* conversion and the credential's field names are copied from the vendored
|
|
14
|
+
* login app's own client components (`register-passkey.tsx`,
|
|
15
|
+
* `login-passkey.tsx`, `helpers/base64.ts`) -- ZITADEL is the judge of what
|
|
16
|
+
* it accepts, not the WebAuthn spec's own `toJSON`, and that app is what
|
|
17
|
+
* ZITADEL already accepts. Not a byte-for-byte port, though: a `null`
|
|
18
|
+
* `userHandle` (a non-discoverable credential need not carry one) is sent as
|
|
19
|
+
* `''` here (see `getPasskey` below), the same answer the login app's own
|
|
20
|
+
* `new Uint8Array(...)` pre-wrap already produces for the same input --
|
|
21
|
+
* `new Uint8Array(null)` is a zero-length view, not a throw -- but is not
|
|
22
|
+
* the byte a naive re-encoding of a `null` would produce.
|
|
23
|
+
*/
|
|
24
|
+
/** Base64url string, or an already-decoded `Array`/`Uint8Array`/`ArrayBuffer`, to `ArrayBuffer`. Mirrors the login app's `coerceToArrayBuffer`. */
|
|
25
|
+
function coerceToArrayBuffer(value, name) {
|
|
26
|
+
let thing = value;
|
|
27
|
+
if (typeof thing === 'string') {
|
|
28
|
+
const base64 = thing.replace(/-/g, '+').replace(/_/g, '/');
|
|
29
|
+
const binary = atob(base64);
|
|
30
|
+
const bytes = new Uint8Array(binary.length);
|
|
31
|
+
for (let i = 0; i < binary.length; i++)
|
|
32
|
+
bytes[i] = binary.charCodeAt(i);
|
|
33
|
+
thing = bytes;
|
|
34
|
+
}
|
|
35
|
+
if (Array.isArray(thing))
|
|
36
|
+
thing = new Uint8Array(thing);
|
|
37
|
+
if (thing instanceof Uint8Array)
|
|
38
|
+
thing = thing.buffer;
|
|
39
|
+
if (!(thing instanceof ArrayBuffer)) {
|
|
40
|
+
throw new TypeError(`@wtfalch/auth: could not coerce '${name}' to ArrayBuffer`);
|
|
41
|
+
}
|
|
42
|
+
return thing;
|
|
43
|
+
}
|
|
44
|
+
/** `ArrayBuffer`/`Uint8Array`/`Array` to a base64url string. Mirrors the login app's `coerceToBase64Url`. */
|
|
45
|
+
function coerceToBase64Url(value, name) {
|
|
46
|
+
let thing = value;
|
|
47
|
+
if (Array.isArray(thing))
|
|
48
|
+
thing = Uint8Array.from(thing);
|
|
49
|
+
if (thing instanceof ArrayBuffer)
|
|
50
|
+
thing = new Uint8Array(thing);
|
|
51
|
+
if (thing instanceof Uint8Array) {
|
|
52
|
+
let str = '';
|
|
53
|
+
for (let i = 0; i < thing.byteLength; i++)
|
|
54
|
+
str += String.fromCharCode(thing[i]);
|
|
55
|
+
thing = btoa(str);
|
|
56
|
+
}
|
|
57
|
+
if (typeof thing !== 'string') {
|
|
58
|
+
throw new Error(`@wtfalch/auth: could not coerce '${name}' to string`);
|
|
59
|
+
}
|
|
60
|
+
return thing.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Registers a passkey: decodes `options` (from `auth.startPasskeyRegistration`)
|
|
64
|
+
* into the `CredentialCreationOptions` the browser wants, calls
|
|
65
|
+
* `navigator.credentials.create`, and re-encodes the result for
|
|
66
|
+
* `auth.finishPasskeyRegistration`.
|
|
67
|
+
*/
|
|
68
|
+
export async function createPasskey(options) {
|
|
69
|
+
const source = options?.publicKey;
|
|
70
|
+
if (!source) {
|
|
71
|
+
throw new Error("@wtfalch/auth: passkey creation options have no 'publicKey'");
|
|
72
|
+
}
|
|
73
|
+
const publicKey = {
|
|
74
|
+
...source,
|
|
75
|
+
challenge: coerceToArrayBuffer(source.challenge, 'challenge'),
|
|
76
|
+
user: { ...source.user, id: coerceToArrayBuffer(source.user.id, 'user.id') },
|
|
77
|
+
...(Array.isArray(source.excludeCredentials)
|
|
78
|
+
? {
|
|
79
|
+
excludeCredentials: source.excludeCredentials.map((cred) => ({
|
|
80
|
+
...cred,
|
|
81
|
+
id: coerceToArrayBuffer(cred.id, 'excludeCredentials.id'),
|
|
82
|
+
})),
|
|
83
|
+
}
|
|
84
|
+
: {}),
|
|
85
|
+
};
|
|
86
|
+
const credential = (await navigator.credentials.create({
|
|
87
|
+
publicKey,
|
|
88
|
+
}));
|
|
89
|
+
if (!credential)
|
|
90
|
+
throw new Error('@wtfalch/auth: the browser returned no credential');
|
|
91
|
+
const response = credential.response;
|
|
92
|
+
return {
|
|
93
|
+
id: credential.id,
|
|
94
|
+
rawId: coerceToBase64Url(credential.rawId, 'rawId'),
|
|
95
|
+
type: credential.type,
|
|
96
|
+
response: {
|
|
97
|
+
attestationObject: coerceToBase64Url(response.attestationObject, 'attestationObject'),
|
|
98
|
+
clientDataJSON: coerceToBase64Url(response.clientDataJSON, 'clientDataJSON'),
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Signs in with a passkey: decodes `options` (from `auth.startPasskeySignIn`)
|
|
104
|
+
* into the `CredentialRequestOptions` the browser wants, calls
|
|
105
|
+
* `navigator.credentials.get`, and re-encodes the result for
|
|
106
|
+
* `auth.finishPasskeySignIn`.
|
|
107
|
+
*/
|
|
108
|
+
export async function getPasskey(options) {
|
|
109
|
+
const source = options?.publicKey;
|
|
110
|
+
if (!source) {
|
|
111
|
+
throw new Error("@wtfalch/auth: passkey request options have no 'publicKey'");
|
|
112
|
+
}
|
|
113
|
+
const publicKey = {
|
|
114
|
+
...source,
|
|
115
|
+
challenge: coerceToArrayBuffer(source.challenge, 'challenge'),
|
|
116
|
+
...(Array.isArray(source.allowCredentials)
|
|
117
|
+
? {
|
|
118
|
+
allowCredentials: source.allowCredentials.map((cred) => ({
|
|
119
|
+
...cred,
|
|
120
|
+
id: coerceToArrayBuffer(cred.id, 'allowCredentials.id'),
|
|
121
|
+
})),
|
|
122
|
+
}
|
|
123
|
+
: {}),
|
|
124
|
+
};
|
|
125
|
+
const credential = (await navigator.credentials.get({
|
|
126
|
+
publicKey,
|
|
127
|
+
}));
|
|
128
|
+
if (!credential)
|
|
129
|
+
throw new Error('@wtfalch/auth: the browser returned no credential');
|
|
130
|
+
const response = credential.response;
|
|
131
|
+
// `new Uint8Array(x)` first, exactly as the login app does: a `null`
|
|
132
|
+
// `userHandle` (a non-discoverable credential need not carry one) becomes
|
|
133
|
+
// a zero-length view rather than a value `coerceToBase64Url` would refuse.
|
|
134
|
+
const authenticatorData = new Uint8Array(response.authenticatorData);
|
|
135
|
+
const clientDataJSON = new Uint8Array(response.clientDataJSON);
|
|
136
|
+
const rawId = new Uint8Array(credential.rawId);
|
|
137
|
+
const signature = new Uint8Array(response.signature);
|
|
138
|
+
const userHandle = new Uint8Array(response.userHandle ?? new ArrayBuffer(0));
|
|
139
|
+
return {
|
|
140
|
+
id: credential.id,
|
|
141
|
+
rawId: coerceToBase64Url(rawId, 'rawId'),
|
|
142
|
+
type: credential.type,
|
|
143
|
+
response: {
|
|
144
|
+
authenticatorData: coerceToBase64Url(authenticatorData, 'authenticatorData'),
|
|
145
|
+
clientDataJSON: coerceToBase64Url(clientDataJSON, 'clientDataJSON'),
|
|
146
|
+
signature: coerceToBase64Url(signature, 'signature'),
|
|
147
|
+
userHandle: coerceToBase64Url(userHandle, 'userHandle'),
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
package/dist/qr.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ResolvedOptions } from './config.js';
|
|
2
|
+
import { type Oidc, type Tokens } from './oidc.js';
|
|
3
|
+
export interface DeviceAuthorization {
|
|
4
|
+
deviceCode: string;
|
|
5
|
+
userCode: string;
|
|
6
|
+
verificationUriComplete: string;
|
|
7
|
+
expiresIn: number;
|
|
8
|
+
interval: number;
|
|
9
|
+
}
|
|
10
|
+
export declare function requestDeviceAuthorization(options: ResolvedOptions, scope: string): Promise<DeviceAuthorization>;
|
|
11
|
+
export type DevicePoll = {
|
|
12
|
+
status: 'pending';
|
|
13
|
+
} | {
|
|
14
|
+
status: 'slow_down';
|
|
15
|
+
} | {
|
|
16
|
+
status: 'expired';
|
|
17
|
+
} | {
|
|
18
|
+
status: 'denied';
|
|
19
|
+
} | {
|
|
20
|
+
status: 'signed_in';
|
|
21
|
+
tokens: Tokens;
|
|
22
|
+
};
|
|
23
|
+
export declare function pollDeviceToken(options: ResolvedOptions, oidc: Oidc, deviceCode: string): Promise<DevicePoll>;
|
package/dist/qr.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { AuthError } from './oidc.js';
|
|
2
|
+
/**
|
|
3
|
+
* The OAuth device authorization grant (RFC 8628), for signing in a new
|
|
4
|
+
* device from a QR code a phone that is already signed in approves. Raw
|
|
5
|
+
* `fetch` against the issuer's own endpoints -- the same convention
|
|
6
|
+
* `browser.ts` and `namespace-browser.ts` use for their token calls -- rather
|
|
7
|
+
* than `openid-client`'s device-grant helpers, which poll synchronously and
|
|
8
|
+
* do not fit a request served once per browser poll.
|
|
9
|
+
*/
|
|
10
|
+
const DEVICE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
|
|
11
|
+
export async function requestDeviceAuthorization(options, scope) {
|
|
12
|
+
const response = await options.fetch(`${options.issuer}/oauth/v2/device_authorization`, {
|
|
13
|
+
method: 'POST',
|
|
14
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
15
|
+
body: new URLSearchParams({ client_id: options.clientId, scope }),
|
|
16
|
+
});
|
|
17
|
+
if (!response.ok) {
|
|
18
|
+
throw new AuthError('request', `the issuer refused the device authorization request (${response.status})`);
|
|
19
|
+
}
|
|
20
|
+
const body = (await response.json().catch(() => null));
|
|
21
|
+
const deviceCode = str(body?.device_code);
|
|
22
|
+
const userCode = str(body?.user_code);
|
|
23
|
+
const verificationUriComplete = str(body?.verification_uri_complete);
|
|
24
|
+
const expiresIn = num(body?.expires_in);
|
|
25
|
+
if (!deviceCode || !userCode || !verificationUriComplete || expiresIn === null) {
|
|
26
|
+
throw new AuthError('request', 'the issuer returned an incomplete device authorization');
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
deviceCode,
|
|
30
|
+
userCode,
|
|
31
|
+
verificationUriComplete,
|
|
32
|
+
expiresIn,
|
|
33
|
+
interval: num(body?.interval) ?? 5,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export async function pollDeviceToken(options, oidc, deviceCode) {
|
|
37
|
+
const response = await options.fetch(`${options.issuer}/oauth/v2/token`, {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
40
|
+
body: new URLSearchParams({
|
|
41
|
+
grant_type: DEVICE_GRANT_TYPE,
|
|
42
|
+
device_code: deviceCode,
|
|
43
|
+
client_id: options.clientId,
|
|
44
|
+
}),
|
|
45
|
+
});
|
|
46
|
+
const body = (await response.json().catch(() => null));
|
|
47
|
+
if (!response.ok) {
|
|
48
|
+
const error = str(body?.error);
|
|
49
|
+
if (error === 'authorization_pending')
|
|
50
|
+
return { status: 'pending' };
|
|
51
|
+
if (error === 'slow_down')
|
|
52
|
+
return { status: 'slow_down' };
|
|
53
|
+
if (error === 'expired_token')
|
|
54
|
+
return { status: 'expired' };
|
|
55
|
+
if (error === 'access_denied')
|
|
56
|
+
return { status: 'denied' };
|
|
57
|
+
throw new AuthError('exchange', str(body?.error_description) ?? error ?? 'device token request failed');
|
|
58
|
+
}
|
|
59
|
+
const idToken = str(body?.id_token);
|
|
60
|
+
if (!idToken)
|
|
61
|
+
throw new AuthError('exchange', 'the issuer returned no id token');
|
|
62
|
+
// Signature, issuer, audience, expiry and the organisation claim -- exactly
|
|
63
|
+
// the code-exchange path's checks, minus the nonce a device grant has none of.
|
|
64
|
+
const claims = await oidc.verify(idToken);
|
|
65
|
+
return {
|
|
66
|
+
status: 'signed_in',
|
|
67
|
+
tokens: { idToken, refreshToken: str(body?.refresh_token) ?? undefined, claims },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function str(value) {
|
|
71
|
+
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
72
|
+
}
|
|
73
|
+
function num(value) {
|
|
74
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
75
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wtfalch/auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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",
|
|
@@ -22,6 +22,10 @@
|
|
|
22
22
|
"types": "./dist/browser.d.ts",
|
|
23
23
|
"default": "./dist/browser.js"
|
|
24
24
|
},
|
|
25
|
+
"./passkey": {
|
|
26
|
+
"types": "./dist/passkey-browser.d.ts",
|
|
27
|
+
"default": "./dist/passkey-browser.js"
|
|
28
|
+
},
|
|
25
29
|
"./namespaces": {
|
|
26
30
|
"types": "./dist/namespaces.d.ts",
|
|
27
31
|
"default": "./dist/namespaces.js"
|