@wtfalch/auth 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -2
- package/dist/auth.d.ts +83 -0
- package/dist/auth.js +147 -7
- package/dist/broker.d.ts +27 -0
- package/dist/broker.js +17 -0
- package/dist/cookies.d.ts +14 -0
- package/dist/cookies.js +22 -0
- package/dist/index.d.ts +1 -1
- package/dist/namespace-server.js +7 -3
- package/dist/namespace-session.js +27 -2
- package/dist/namespaces.d.ts +76 -2
- package/dist/namespaces.js +58 -9
- package/dist/next.d.ts +49 -2
- package/dist/next.js +51 -0
- package/dist/oidc.d.ts +5 -1
- package/dist/oidc.js +12 -8
- package/dist/passkey-browser.d.ts +83 -0
- package/dist/passkey-browser.js +150 -0
- package/dist/qr.d.ts +23 -0
- package/dist/qr.js +75 -0
- package/package.json +6 -2
package/dist/namespaces.d.ts
CHANGED
|
@@ -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:
|
|
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: (
|
|
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;
|
package/dist/namespaces.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
+
}
|