@wtfalch/auth 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,9 @@ export interface NamespaceDomain {
9
9
  /** Operator-owned evidence reference; this is not an automated DNS verifier. */
10
10
  verificationRef?: string;
11
11
  }
12
+ /** A browser service: the SDK's server or browser client at one origin. `kind` is omitted. */
12
13
  export interface NamespaceService {
14
+ kind?: 'web';
13
15
  id: string;
14
16
  serviceId: string;
15
17
  deploymentId: string;
@@ -18,12 +20,56 @@ export interface NamespaceService {
18
20
  postLogoutRedirectUris: string[];
19
21
  credentialRef: string;
20
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;
21
36
  }
37
+ /**
38
+ * Installed software a person signs in to by approving on another device: a
39
+ * public device-code client with no secret and no redirect URIs.
40
+ */
41
+ export interface NamespaceNativeService {
42
+ kind: 'native';
43
+ id: string;
44
+ serviceId: string;
45
+ deploymentId: string;
46
+ }
47
+ /**
48
+ * A resource server that checks the namespace's tokens at the introspection
49
+ * endpoint. Its client secret is stored under `credentialRef`, never here.
50
+ */
51
+ export interface NamespaceApiService {
52
+ kind: 'api';
53
+ id: string;
54
+ serviceId: string;
55
+ deploymentId: string;
56
+ credentialRef: string;
57
+ }
58
+ /** Every client of one namespace shares its accounts: one sign-in across all of them. */
59
+ export type NamespaceBinding = NamespaceService | NamespaceNativeService | NamespaceApiService;
60
+ export declare function isWebService<T extends NamespaceBinding>(binding: T): binding is Extract<T, NamespaceService>;
22
61
  export interface NamespaceSpec {
23
62
  id: string;
24
63
  displayName: string;
25
64
  revision: number;
26
65
  status: Exclude<NamespaceStatus, 'failed'>;
66
+ /**
67
+ * `invitation`: nobody signs themselves up. People arrive by invitation,
68
+ * whether from the organization's admin or when a mailbox is created on
69
+ * one of its `account` domains. Omitted means open, which is how every
70
+ * namespace behaved before.
71
+ */
72
+ admission?: 'invitation';
27
73
  loginOrigin: string;
28
74
  domains: NamespaceDomain[];
29
75
  mail: {
@@ -31,7 +77,7 @@ export interface NamespaceSpec {
31
77
  productName: string;
32
78
  locale: string;
33
79
  };
34
- services: NamespaceService[];
80
+ services: NamespaceBinding[];
35
81
  }
36
82
  export interface NamespaceManifest {
37
83
  version: 1;
@@ -42,7 +88,7 @@ export interface NamespaceRecord extends Omit<NamespaceSpec, 'status' | 'service
42
88
  status: NamespaceStatus;
43
89
  organizationId: string;
44
90
  projectId: string;
45
- services: (NamespaceService & {
91
+ services: (NamespaceBinding & {
46
92
  appId: string;
47
93
  clientId: string;
48
94
  })[];
@@ -66,6 +112,7 @@ export declare function resolveNamespaceBinding(registry: NamespaceRegistry, inp
66
112
  deploymentId: string;
67
113
  appOrigin: string;
68
114
  }): {
115
+ kind?: "web";
69
116
  id: string;
70
117
  serviceId: string;
71
118
  deploymentId: string;
@@ -74,6 +121,19 @@ export declare function resolveNamespaceBinding(registry: NamespaceRegistry, inp
74
121
  postLogoutRedirectUris: string[];
75
122
  credentialRef: string;
76
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;
77
137
  appId: string;
78
138
  clientId: string;
79
139
  issuer: string;
@@ -100,6 +160,7 @@ export declare function resolveNamespaceContext(selection: NamespaceSelection, o
100
160
  organizationId?: string;
101
161
  }): {
102
162
  context: string;
163
+ kind?: "web";
103
164
  id: string;
104
165
  serviceId: string;
105
166
  deploymentId: string;
@@ -108,6 +169,19 @@ export declare function resolveNamespaceContext(selection: NamespaceSelection, o
108
169
  postLogoutRedirectUris: string[];
109
170
  credentialRef: string;
110
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;
111
185
  appId: string;
112
186
  clientId: string;
113
187
  issuer: string;
@@ -1,3 +1,6 @@
1
+ export function isWebService(binding) {
2
+ return binding.kind === undefined || binding.kind === 'web';
3
+ }
1
4
  function invalid(field) {
2
5
  throw new Error(`Invalid namespace configuration: ${field}`);
3
6
  }
@@ -77,23 +80,57 @@ function domain(value) {
77
80
  invalid('active domain needs verificationRef');
78
81
  return { domain: name, purpose, status, ...(verificationRef ? { verificationRef } : {}) };
79
82
  }
83
+ function credential(value) {
84
+ const credentialRef = string(value, 'credentialRef');
85
+ if (!/^[A-Z][A-Z0-9_]{2,100}$/.test(credentialRef) || credentialRef.endsWith('_PAT'))
86
+ invalid('credentialRef');
87
+ return credentialRef;
88
+ }
89
+ const WEB_FIELDS = [
90
+ 'appOrigin',
91
+ 'redirectUris',
92
+ 'postLogoutRedirectUris',
93
+ 'registration',
94
+ 'emailLink',
95
+ 'qrSignIn',
96
+ ];
80
97
  function service(value) {
81
98
  const s = object(value, 'service');
99
+ const kind = s.kind === undefined ? 'web' : oneOf(s.kind, ['web', 'native', 'api'], 'kind');
100
+ const identity = {
101
+ id: id(s.id, 'binding id'),
102
+ serviceId: id(s.serviceId, 'serviceId'),
103
+ deploymentId: id(s.deploymentId, 'deploymentId'),
104
+ };
105
+ if (kind !== 'web') {
106
+ // Nothing is redirected to installed software or a resource server; a
107
+ // stray browser field is a mistaken kind, not something to ignore.
108
+ if (WEB_FIELDS.some((field) => s[field] !== undefined))
109
+ invalid(`${kind} service web fields`);
110
+ if (kind === 'native') {
111
+ if (s.credentialRef !== undefined)
112
+ invalid('native service credentialRef');
113
+ return { kind, ...identity };
114
+ }
115
+ return { kind, ...identity, credentialRef: credential(s.credentialRef) };
116
+ }
82
117
  const appOrigin = url(s.appOrigin, 'appOrigin', true, true);
83
- const credentialRef = string(s.credentialRef, 'credentialRef');
84
- if (!/^[A-Z][A-Z0-9_]{2,100}$/.test(credentialRef) || credentialRef.endsWith('_PAT'))
85
- invalid('credentialRef');
118
+ const credentialRef = credential(s.credentialRef);
86
119
  if (s.registration !== undefined && typeof s.registration !== 'boolean')
87
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');
88
125
  return {
89
- id: id(s.id, 'binding id'),
90
- serviceId: id(s.serviceId, 'serviceId'),
91
- deploymentId: id(s.deploymentId, 'deploymentId'),
126
+ ...identity,
92
127
  appOrigin,
93
128
  redirectUris: uris(s.redirectUris, appOrigin, 'redirectUris'),
94
129
  postLogoutRedirectUris: uris(s.postLogoutRedirectUris, appOrigin, 'postLogoutRedirectUris'),
95
130
  credentialRef,
96
131
  registration: s.registration === true,
132
+ ...(s.emailLink === true ? { emailLink: true } : {}),
133
+ ...(s.qrSignIn === true ? { qrSignIn: true } : {}),
97
134
  };
98
135
  }
99
136
  function parse(raw, resolved) {
@@ -129,11 +166,21 @@ function parse(raw, resolved) {
129
166
  if (!services.length)
130
167
  invalid('namespace needs a service');
131
168
  unique(services.map((s) => `${s.serviceId}:${s.deploymentId}`), 'namespace service/deployment');
169
+ const admission = oneOf(n.admission ?? 'open', ['open', 'invitation'], 'admission');
170
+ if (admission === 'invitation') {
171
+ // The organization's addresses are what an invitation-only namespace
172
+ // is for: mail provisioning invites from them.
173
+ if (!domains.some((d) => d.purpose === 'account'))
174
+ invalid('invitation namespace needs an account domain');
175
+ if (services.some((s) => isWebService(s) && s.registration))
176
+ invalid('invitation namespace cannot open registration');
177
+ }
132
178
  const common = {
133
179
  id: id(n.id, 'namespace id'),
134
180
  displayName: string(n.displayName, 'displayName'),
135
181
  revision: n.revision,
136
182
  status,
183
+ ...(admission === 'invitation' ? { admission } : {}),
137
184
  loginOrigin,
138
185
  domains,
139
186
  services,
@@ -153,7 +200,7 @@ function parse(raw, resolved) {
153
200
  });
154
201
  unique(namespaces.map((n) => n.id), 'namespace id');
155
202
  unique(namespaces.flatMap((n) => n.services.map((s) => s.id)), 'binding id');
156
- unique(namespaces.flatMap((n) => n.services.map((s) => s.credentialRef)), 'credential reference');
203
+ unique(namespaces.flatMap((n) => n.services.flatMap((s) => ('credentialRef' in s ? [s.credentialRef] : []))), 'credential reference');
157
204
  const domains = new Map();
158
205
  for (const n of namespaces)
159
206
  for (const d of n.domains) {
@@ -183,7 +230,9 @@ export function resolveNamespaceBinding(registry, input) {
183
230
  const namespace = validated.namespaces.find((n) => n.id === input.namespaceId);
184
231
  if (!namespace || namespace.status !== 'active')
185
232
  invalid('namespace unavailable');
186
- const binding = namespace.services.find((s) => s.serviceId === input.serviceId && s.deploymentId === input.deploymentId);
233
+ const binding = namespace.services
234
+ .filter(isWebService)
235
+ .find((s) => s.serviceId === input.serviceId && s.deploymentId === input.deploymentId);
187
236
  if (!binding || binding.appOrigin !== url(input.appOrigin, 'appOrigin', true, true))
188
237
  invalid('service binding mismatch');
189
238
  return {
@@ -206,7 +255,7 @@ export function parseWorkspaceBindings(raw, registry) {
206
255
  namespaceId: id(w.namespaceId, 'namespaceId'),
207
256
  };
208
257
  const namespace = validated.namespaces.find((n) => n.id === binding.namespaceId);
209
- if (!namespace?.services.some((s) => s.serviceId === binding.serviceId))
258
+ if (!namespace?.services.some((s) => isWebService(s) && s.serviceId === binding.serviceId))
210
259
  invalid('workspace service/namespace mismatch');
211
260
  return binding;
212
261
  });
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
+ }