@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 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, and the
146
- checks to run against a real issuer.
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
- // Signing in again replaces this binding's previous session outright.
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';
@@ -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;
@@ -86,7 +86,14 @@ function credential(value) {
86
86
  invalid('credentialRef');
87
87
  return credentialRef;
88
88
  }
89
- const WEB_FIELDS = ['appOrigin', 'redirectUris', 'postLogoutRedirectUris', 'registration'];
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.7.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"