@wtfalch/auth 0.1.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/dist/auth.d.ts ADDED
@@ -0,0 +1,136 @@
1
+ import { type JWTPayload } from 'jose';
2
+ import { type AuthRequest, type NewUser } from './broker.js';
3
+ import { type AuthOptions } from './config.js';
4
+ import { type SetCookie } from './cookies.js';
5
+ export interface User {
6
+ id: string;
7
+ email: string | null;
8
+ emailVerified: boolean;
9
+ name: string | null;
10
+ givenName: string | null;
11
+ familyName: string | null;
12
+ preferredUsername: string | null;
13
+ organizationId: string;
14
+ claims: JWTPayload;
15
+ }
16
+ export interface ReadResult {
17
+ user: User | null;
18
+ /** What the response must set. Empty unless the session was refreshed or ended. */
19
+ cookies: SetCookie[];
20
+ }
21
+ export interface GateRules {
22
+ /** Paths that need no session. `/docs/*` covers `/docs` and everything under it. The SDK's own routes are always public. */
23
+ public?: string[] | ((pathname: string) => boolean);
24
+ }
25
+ export type Gate = {
26
+ kind: 'next';
27
+ user: User | null;
28
+ cookies: SetCookie[];
29
+ } | {
30
+ kind: 'redirect';
31
+ location: string;
32
+ cookies: SetCookie[];
33
+ }
34
+ /** A fetch, prefetch or action without a session: 401, never a redirect into the sign-in route. */
35
+ | {
36
+ kind: 'deny';
37
+ cookies: SetCookie[];
38
+ };
39
+ export type Intent = 'login' | 'register';
40
+ export type SignInError = 'invalid_credentials' | 'request' | 'unavailable';
41
+ export type ResetError = 'invalid_code' | 'invalid_password' | 'unavailable';
42
+ export type SignUpError = 'email_taken' | 'invalid' | 'request' | 'unavailable';
43
+ export type SignInResult = {
44
+ ok: true;
45
+ redirectTo: string;
46
+ } | {
47
+ ok: false;
48
+ error: SignInError;
49
+ };
50
+ export type SignUpResult = {
51
+ ok: true;
52
+ redirectTo: string;
53
+ } | {
54
+ ok: false;
55
+ error: SignUpError;
56
+ message: string;
57
+ };
58
+ export interface Completed {
59
+ location: string;
60
+ cookies: SetCookie[];
61
+ }
62
+ /** A person signed in from a link rather than from a flow the browser started. */
63
+ export type SignedIn = {
64
+ ok: true;
65
+ location: string;
66
+ cookies: SetCookie[];
67
+ } | {
68
+ ok: false;
69
+ error: ResetError;
70
+ message: string;
71
+ };
72
+ export interface Auth {
73
+ readonly sessionCookieName: string;
74
+ /** GET {basePath}/start?next=&intent= : begins the OIDC flow; the issuer sends the browser to {basePath}/login?authRequest= */
75
+ start(request: Request): Promise<Response>;
76
+ /** GET {basePath}/callback */
77
+ callback(request: Request): Promise<Response>;
78
+ /** The callback as data, for a server action that finishes the sign-in itself and sets the cookies. */
79
+ complete(callbackUrl: string, cookieHeader: string | null): Promise<Completed>;
80
+ /** POST {basePath}/logout */
81
+ logout(request: Request): Promise<Response>;
82
+ /** GET {basePath}/verify?userId=&code= */
83
+ verify(request: Request): Promise<Response>;
84
+ /** GET {basePath}/link?sessionId=&code= : the sign-in link from an email, on any device. */
85
+ link(request: Request): Promise<Response>;
86
+ /** Emails a fresh verification link. Says nothing about whether the address exists. */
87
+ resendVerification(email: string, next?: string): Promise<void>;
88
+ /** Emails a password-reset link to {basePath}/reset?userId=&code=. Says nothing about whether the address exists. */
89
+ requestPasswordReset(email: string, next?: string): Promise<void>;
90
+ /** Sets the password from a reset link and signs the person in. */
91
+ resetPassword(input: {
92
+ userId: string;
93
+ code: string;
94
+ password: string;
95
+ next?: string;
96
+ }): Promise<SignedIn>;
97
+ /** Emails a sign-in link. Needs a verified address. Says nothing about whether the address exists. */
98
+ sendLink(email: string, next?: string): Promise<void>;
99
+ /**
100
+ * Brings a person into this app's organisation and mails them a link to
101
+ * choose a password. Unlike everything else here it says whether the
102
+ * address was already known, because the caller is an operator who asked
103
+ * for it rather than a stranger at a form.
104
+ */
105
+ invite(input: {
106
+ email: string;
107
+ givenName: string;
108
+ familyName: string;
109
+ next?: string;
110
+ }): Promise<{
111
+ userId: string;
112
+ invited: boolean;
113
+ }>;
114
+ handle(request: Request): Promise<Response>;
115
+ /** `refresh: false` for callers that cannot set cookies; they get a user only while the token is valid. */
116
+ read(request: Request, opts?: {
117
+ refresh?: boolean;
118
+ }): Promise<ReadResult>;
119
+ readCookie(value: string | undefined, opts?: {
120
+ refresh?: boolean;
121
+ }): Promise<ReadResult>;
122
+ gate(request: Request, rules?: GateRules): Promise<Gate>;
123
+ startUrl(next?: string | null, intent?: Intent): string;
124
+ /** The auth request the issuer sent the browser here with. Refuses one for another application. */
125
+ authRequest(id: string): Promise<AuthRequest>;
126
+ signIn(input: {
127
+ authRequestId: string;
128
+ email: string;
129
+ password: string;
130
+ }): Promise<SignInResult>;
131
+ signUp(input: {
132
+ authRequestId: string;
133
+ } & NewUser): Promise<SignUpResult>;
134
+ }
135
+ export declare function createAuth(input: AuthOptions): Auth;
136
+ export type { AuthRequest, NewUser, SetCookie };
package/dist/auth.js ADDED
@@ -0,0 +1,403 @@
1
+ import { errors } from 'jose';
2
+ import * as client from 'openid-client';
3
+ import { Broker, BrokerError } from './broker.js';
4
+ import { resolveOptions } from './config.js';
5
+ import { clearSession, clearTransaction, cookieFrom, openSession, openTransaction, sealSession, sealTransaction, sessionCookieName, transactionCookieName, } from './cookies.js';
6
+ import { AuthError, ORG_CLAIM, Oidc } from './oidc.js';
7
+ import { safeNextPath } from './redirect.js';
8
+ export function createAuth(input) {
9
+ // Resolved on first use so `next build`, which imports every route module, needs none of the values set.
10
+ let resolved = null;
11
+ const options = () => {
12
+ resolved ??= resolveOptions(input);
13
+ return resolved;
14
+ };
15
+ let oidcInstance = null;
16
+ const oidc = () => {
17
+ oidcInstance ??= new Oidc(options());
18
+ return oidcInstance;
19
+ };
20
+ let brokerInstance = null;
21
+ const broker = () => {
22
+ brokerInstance ??= new Broker(options());
23
+ return brokerInstance;
24
+ };
25
+ const startUrl = (next, intent = 'login') => {
26
+ const url = new URL(`${options().basePath}/start`, options().appUrl);
27
+ const path = safeNextPath(next, '');
28
+ if (path)
29
+ url.searchParams.set('next', path);
30
+ if (intent === 'register')
31
+ url.searchParams.set('intent', 'register');
32
+ return url.href;
33
+ };
34
+ const start = async (request) => {
35
+ if (!isNavigation(request))
36
+ return new Response('Unauthorized', { status: 401 });
37
+ const params = new URL(request.url).searchParams;
38
+ const next = safeNextPath(params.get('next'), options().afterLogin);
39
+ const state = client.randomState();
40
+ const nonce = client.randomNonce();
41
+ const codeVerifier = client.randomPKCECodeVerifier();
42
+ const target = await oidc().authorizationUrl(state, nonce, codeVerifier, {
43
+ create: params.get('intent') === 'register',
44
+ });
45
+ const transaction = await sealTransaction(options(), {
46
+ st: state,
47
+ nc: nonce,
48
+ cv: codeVerifier,
49
+ nx: next,
50
+ });
51
+ return redirect(target.href, [transaction]);
52
+ };
53
+ const complete = async (callbackUrl, cookieHeader) => {
54
+ const cleared = clearTransaction(options());
55
+ const transaction = await openTransaction(options(), cookieFrom(cookieHeader, transactionCookieName(options())));
56
+ if (!transaction)
57
+ return failure('expired', [cleared]);
58
+ const params = new URL(callbackUrl).searchParams;
59
+ if (params.get('state') !== transaction.st)
60
+ return failure('state', [cleared]);
61
+ let session;
62
+ try {
63
+ const tokens = await oidc().exchange(params, {
64
+ state: transaction.st,
65
+ nonce: transaction.nc,
66
+ codeVerifier: transaction.cv,
67
+ });
68
+ session = await sealSession(options(), { idt: tokens.idToken, rt: tokens.refreshToken });
69
+ }
70
+ catch (error) {
71
+ return failure(reasonOf(error), [cleared]);
72
+ }
73
+ return {
74
+ location: new URL(transaction.nx, options().appUrl).href,
75
+ cookies: [cleared, session],
76
+ };
77
+ };
78
+ const callback = async (request) => {
79
+ const { location, cookies } = await complete(request.url, request.headers.get('cookie'));
80
+ return redirect(location, cookies);
81
+ };
82
+ const logout = async (request) => {
83
+ if (!isSameOrigin(request, options().appUrl.origin)) {
84
+ return new Response('Forbidden', { status: 403 });
85
+ }
86
+ const session = await openSession(options(), cookieFrom(request.headers.get('cookie'), sessionCookieName(options())));
87
+ if (session?.rt)
88
+ await broker()
89
+ .revoke(session.rt)
90
+ .catch(() => { });
91
+ return redirect(new URL(options().afterLogout, options().appUrl).href, [clearSession(options())], 303);
92
+ };
93
+ const verify = async (request) => {
94
+ const params = new URL(request.url).searchParams;
95
+ const userId = params.get('userId');
96
+ const code = params.get('code');
97
+ const url = new URL(safeNextPath(params.get('next'), options().afterVerify), options().appUrl);
98
+ if (!userId || !code)
99
+ return failed('verify', []);
100
+ try {
101
+ await broker().verifyEmail(userId, code);
102
+ }
103
+ catch {
104
+ return failed('verify', []);
105
+ }
106
+ // The id token in the cookie still says unverified; the claim is what the
107
+ // app reads, so it is refreshed here rather than at the next expiry.
108
+ const cookies = [];
109
+ const session = await openSession(options(), cookieFrom(request.headers.get('cookie'), sessionCookieName(options())));
110
+ if (session?.rt) {
111
+ try {
112
+ const tokens = await oidc().refresh(session.rt);
113
+ cookies.push(await sealSession(options(), { idt: tokens.idToken, rt: tokens.refreshToken }));
114
+ }
115
+ catch { }
116
+ }
117
+ url.searchParams.set('verified', '1');
118
+ return redirect(url.href, cookies);
119
+ };
120
+ const handle = async (request) => {
121
+ const { pathname } = new URL(request.url);
122
+ const route = pathname.startsWith(`${options().basePath}/`)
123
+ ? pathname.slice(options().basePath.length + 1)
124
+ : null;
125
+ if (route === 'start' && request.method === 'GET')
126
+ return start(request);
127
+ if (route === 'callback' && request.method === 'GET')
128
+ return callback(request);
129
+ if (route === 'verify' && request.method === 'GET')
130
+ return verify(request);
131
+ if (route === 'link' && request.method === 'GET')
132
+ return link(request);
133
+ if (route === 'logout' && request.method === 'POST')
134
+ return logout(request);
135
+ return new Response('Not found', { status: 404 });
136
+ };
137
+ const readCookie = async (value, { refresh = true } = {}) => {
138
+ const session = await openSession(options(), value);
139
+ if (!session)
140
+ return { user: null, cookies: [] };
141
+ const now = Math.floor(Date.now() / 1000);
142
+ const exp = expiryOf(session.idt);
143
+ const valid = exp !== null && exp > now - 60;
144
+ const due = exp === null || exp - options().refreshWindow <= now;
145
+ if (due && refresh && session.rt) {
146
+ try {
147
+ const tokens = await oidc().refresh(session.rt);
148
+ const cookie = await sealSession(options(), {
149
+ idt: tokens.idToken,
150
+ rt: tokens.refreshToken,
151
+ });
152
+ return { user: userFrom(tokens.claims), cookies: [cookie] };
153
+ }
154
+ catch (error) {
155
+ // A failed refresh never clears the cookie: a valid token carries on, and a dead one is replaced by the next sign-in.
156
+ if (error instanceof AuthError)
157
+ return { user: null, cookies: [clearSession(options())] };
158
+ if (!valid)
159
+ return { user: null, cookies: [] };
160
+ }
161
+ }
162
+ try {
163
+ const claims = await oidc().verify(session.idt);
164
+ return { user: userFrom(claims), cookies: [] };
165
+ }
166
+ catch (error) {
167
+ return { user: null, cookies: refresh && rejected(error) ? [clearSession(options())] : [] };
168
+ }
169
+ };
170
+ const read = (request, opts) => readCookie(cookieFrom(request.headers.get('cookie'), sessionCookieName(options())), opts);
171
+ const gate = async (request, rules = {}) => {
172
+ const url = new URL(request.url);
173
+ const isPublic = url.pathname === options().basePath ||
174
+ url.pathname.startsWith(`${options().basePath}/`) ||
175
+ matches(rules.public, url.pathname);
176
+ const { user, cookies } = await read(request);
177
+ if (user || isPublic)
178
+ return { kind: 'next', user, cookies };
179
+ if (!isNavigation(request))
180
+ return { kind: 'deny', cookies };
181
+ return { kind: 'redirect', location: startUrl(`${url.pathname}${url.search}`), cookies };
182
+ };
183
+ const authRequest = (id) => broker().authRequest(id);
184
+ /**
185
+ * An OIDC flow this app starts and finishes itself, for a person arriving
186
+ * from an email rather than from a browser the app sent to the issuer.
187
+ */
188
+ const serverFlow = async () => {
189
+ const state = client.randomState();
190
+ const nonce = client.randomNonce();
191
+ const verifier = client.randomPKCECodeVerifier();
192
+ const target = await oidc().authorizationUrl(state, nonce, verifier);
193
+ const response = await options().fetch(target.href, { redirect: 'manual' });
194
+ const location = response.headers.get('location');
195
+ const id = location && new URL(location, options().issuer).searchParams.get('authRequest');
196
+ if (!id)
197
+ throw new AuthError('request', 'the issuer did not start an auth request');
198
+ return { id, state, nonce, verifier };
199
+ };
200
+ /** Exchanges a callback the service issued, and seals the cookie. */
201
+ const finish = async (callbackUrl, flow, next) => {
202
+ const tokens = await oidc().exchange(new URL(callbackUrl).searchParams, {
203
+ state: flow.state,
204
+ nonce: flow.nonce,
205
+ codeVerifier: flow.verifier,
206
+ });
207
+ const cookie = await sealSession(options(), { idt: tokens.idToken, rt: tokens.refreshToken });
208
+ return { location: new URL(next, options().appUrl).href, cookies: [cookie] };
209
+ };
210
+ const resendVerification = (email, next) => quietly(() => broker().sendVerification(email, safeNextPath(next, '')));
211
+ const requestPasswordReset = (email, next) => quietly(() => broker().sendReset(email, safeNextPath(next, '')));
212
+ const sendLink = (email, next) => quietly(() => broker().sendLink(email, safeNextPath(next, '')));
213
+ const invite = (input) => broker().invite({ ...input, next: safeNextPath(input.next, '') });
214
+ const resetPassword = async ({ userId, code, password, next, }) => {
215
+ try {
216
+ const flow = await serverFlow();
217
+ const callbackUrl = await broker().completeReset({
218
+ authRequestId: flow.id,
219
+ userId,
220
+ code,
221
+ password,
222
+ });
223
+ const { location, cookies } = await finish(callbackUrl, flow, safeNextPath(next, options().afterLogin));
224
+ return { ok: true, location, cookies };
225
+ }
226
+ catch (error) {
227
+ if (error instanceof BrokerError && error.error === 'invalid_password') {
228
+ return { ok: false, error: 'invalid_password', message: error.detail ?? error.error };
229
+ }
230
+ if (error instanceof BrokerError && error.status < 500) {
231
+ return { ok: false, error: 'invalid_code', message: error.detail ?? error.error };
232
+ }
233
+ return { ok: false, error: 'unavailable', message: 'the sign-in service did not answer' };
234
+ }
235
+ };
236
+ const link = async (request) => {
237
+ const params = new URL(request.url).searchParams;
238
+ const sessionId = params.get('sessionId');
239
+ const code = params.get('code');
240
+ if (!sessionId || !code)
241
+ return failed('link', []);
242
+ try {
243
+ const flow = await serverFlow();
244
+ const callbackUrl = await broker().followLink({ authRequestId: flow.id, sessionId, code });
245
+ const { location, cookies } = await finish(callbackUrl, flow, safeNextPath(params.get('next'), options().afterLogin));
246
+ return redirect(location, cookies);
247
+ }
248
+ catch {
249
+ return failed('link', []);
250
+ }
251
+ };
252
+ const signIn = async ({ authRequestId, email, password, }) => {
253
+ try {
254
+ return { ok: true, redirectTo: await broker().signIn({ authRequestId, email, password }) };
255
+ }
256
+ catch (error) {
257
+ return { ok: false, error: signInError(error) };
258
+ }
259
+ };
260
+ const signUp = async ({ authRequestId, ...user }) => {
261
+ try {
262
+ return { ok: true, redirectTo: await broker().signUp({ authRequestId, ...user }) };
263
+ }
264
+ catch (error) {
265
+ if (error instanceof BrokerError && error.error === 'email_taken') {
266
+ return { ok: false, error: 'email_taken', message: error.detail ?? error.error };
267
+ }
268
+ if (error instanceof BrokerError && error.error === 'request') {
269
+ return { ok: false, error: 'request', message: error.detail ?? error.error };
270
+ }
271
+ if (error instanceof BrokerError && error.status < 500) {
272
+ return { ok: false, error: 'invalid', message: error.detail ?? error.error };
273
+ }
274
+ return { ok: false, error: 'unavailable', message: 'the sign-in service did not answer' };
275
+ }
276
+ };
277
+ const failure = (reason, cookies) => {
278
+ const url = new URL(options().onError, options().appUrl);
279
+ url.searchParams.set('auth_error', reason);
280
+ return { location: url.href, cookies };
281
+ };
282
+ const failed = (reason, cookies) => {
283
+ const { location } = failure(reason, []);
284
+ return redirect(location, cookies);
285
+ };
286
+ return {
287
+ get sessionCookieName() {
288
+ return sessionCookieName(options());
289
+ },
290
+ start,
291
+ callback,
292
+ complete,
293
+ logout,
294
+ verify,
295
+ link,
296
+ resendVerification,
297
+ requestPasswordReset,
298
+ resetPassword,
299
+ sendLink,
300
+ invite,
301
+ handle,
302
+ read,
303
+ readCookie,
304
+ gate,
305
+ startUrl,
306
+ authRequest,
307
+ signIn,
308
+ signUp,
309
+ };
310
+ }
311
+ function redirect(location, cookies, status = 302) {
312
+ const headers = new Headers({ Location: location });
313
+ for (const cookie of cookies)
314
+ headers.append('Set-Cookie', cookie.header);
315
+ return new Response(null, { status, headers });
316
+ }
317
+ function matches(rule, pathname) {
318
+ if (!rule)
319
+ return false;
320
+ if (typeof rule === 'function')
321
+ return rule(pathname);
322
+ return rule.some((entry) => {
323
+ if (entry.endsWith('/*')) {
324
+ const prefix = entry.slice(0, -2) || '/';
325
+ return pathname === prefix || pathname.startsWith(prefix === '/' ? '/' : `${prefix}/`);
326
+ }
327
+ return pathname === entry;
328
+ });
329
+ }
330
+ function expiryOf(idToken) {
331
+ try {
332
+ const [, payload] = idToken.split('.');
333
+ const json = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
334
+ return typeof json.exp === 'number' ? json.exp : null;
335
+ }
336
+ catch {
337
+ return null;
338
+ }
339
+ }
340
+ function userFrom(claims) {
341
+ const str = (key) => typeof claims[key] === 'string' ? claims[key] : null;
342
+ return {
343
+ id: String(claims.sub),
344
+ email: str('email'),
345
+ emailVerified: claims.email_verified === true,
346
+ name: str('name'),
347
+ givenName: str('given_name'),
348
+ familyName: str('family_name'),
349
+ preferredUsername: str('preferred_username'),
350
+ organizationId: String(claims[ORG_CLAIM]),
351
+ claims,
352
+ };
353
+ }
354
+ // The token itself is bad, as opposed to the keys being unreachable or rotating.
355
+ function rejected(error) {
356
+ return (error instanceof AuthError ||
357
+ error instanceof errors.JWTExpired ||
358
+ error instanceof errors.JWTClaimValidationFailed ||
359
+ error instanceof errors.JWSSignatureVerificationFailed ||
360
+ error instanceof errors.JWSInvalid ||
361
+ error instanceof errors.JWTInvalid ||
362
+ error instanceof errors.JOSEAlgNotAllowed);
363
+ }
364
+ function isNavigation(request) {
365
+ const mode = request.headers.get('sec-fetch-mode');
366
+ if (mode && mode !== 'navigate')
367
+ return false;
368
+ return !request.headers.has('rsc') && !request.headers.has('next-action');
369
+ }
370
+ function isSameOrigin(request, origin) {
371
+ const from = request.headers.get('origin');
372
+ if (from)
373
+ return from === origin;
374
+ return request.headers.get('sec-fetch-site') === 'same-origin';
375
+ }
376
+ // Never says whether the address exists, so neither can the app.
377
+ async function quietly(act) {
378
+ try {
379
+ await act();
380
+ }
381
+ catch (error) {
382
+ if (error instanceof BrokerError && error.status < 500)
383
+ return;
384
+ throw error;
385
+ }
386
+ }
387
+ function signInError(error) {
388
+ if (error instanceof BrokerError && error.error === 'request')
389
+ return 'request';
390
+ // A key the service will not take is the app's problem, not the visitor's.
391
+ if (error instanceof BrokerError && error.error === 'unauthorized')
392
+ return 'unavailable';
393
+ if (error instanceof BrokerError && error.status < 500)
394
+ return 'invalid_credentials';
395
+ return 'unavailable';
396
+ }
397
+ function reasonOf(error) {
398
+ if (error instanceof AuthError)
399
+ return error.reason;
400
+ if (error instanceof client.AuthorizationResponseError)
401
+ return 'denied';
402
+ return 'exchange';
403
+ }
@@ -0,0 +1,63 @@
1
+ import type { ResolvedOptions } from './config.js';
2
+ export interface AuthRequest {
3
+ id: string;
4
+ intent: 'login' | 'register';
5
+ loginHint: string | null;
6
+ }
7
+ export interface NewUser {
8
+ email: string;
9
+ password: string;
10
+ givenName: string;
11
+ familyName: string;
12
+ }
13
+ export declare class BrokerError extends Error {
14
+ readonly status: number;
15
+ readonly error: string;
16
+ readonly detail?: string | undefined;
17
+ constructor(status: number, error: string, detail?: string | undefined);
18
+ }
19
+ /**
20
+ * The privileged half, over HTTP. The app holds a key that reaches its own
21
+ * organisation through these calls only; the token that can sign anyone in
22
+ * anywhere stays in the service.
23
+ */
24
+ export declare class Broker {
25
+ private readonly options;
26
+ constructor(options: ResolvedOptions);
27
+ private call;
28
+ authRequest(id: string): Promise<AuthRequest>;
29
+ signIn(input: {
30
+ authRequestId: string;
31
+ email: string;
32
+ password: string;
33
+ }): Promise<string>;
34
+ signUp(input: {
35
+ authRequestId: string;
36
+ } & NewUser): Promise<string>;
37
+ followLink(input: {
38
+ authRequestId: string;
39
+ sessionId: string;
40
+ code: string;
41
+ }): Promise<string>;
42
+ completeReset(input: {
43
+ authRequestId: string;
44
+ userId: string;
45
+ code: string;
46
+ password: string;
47
+ }): Promise<string>;
48
+ invite(input: {
49
+ email: string;
50
+ givenName: string;
51
+ familyName: string;
52
+ next: string;
53
+ }): Promise<{
54
+ userId: string;
55
+ invited: boolean;
56
+ }>;
57
+ sendLink(email: string, next: string): Promise<unknown>;
58
+ sendReset(email: string, next: string): Promise<unknown>;
59
+ sendVerification(email: string, next: string): Promise<unknown>;
60
+ verifyEmail(userId: string, code: string): Promise<unknown>;
61
+ /** A public client revoking its own token: no key, no service. */
62
+ revoke(refreshToken: string): Promise<void>;
63
+ }