@wtfalch/auth 0.32.0 → 0.34.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/next.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type NextRequest, NextResponse } from 'next/server';
2
2
  import { type Auth, type AuthRequest, type ChangePasswordResult, type DeletePasskeyResult, type EmailChangeResult, type GateRules, type Intent, type MaxAge, type NewUser, type Passkey, type PasskeyRegistrationResult, type PasskeyRegistrationStart, type PasskeySignInStart, type Prompt, type QrPollStatus, type ResetError, type SetNewPasswordError, type SetNewPasswordResult, type SignInError, type SignUpError, type User } from './auth.js';
3
3
  import type { AuthEvent, AuthOptions } from './config.js';
4
+ import type { FreshSignInAsk } from './fresh.js';
4
5
  import type { SessionRecord } from './sessions.js';
5
6
  type SearchParams = Record<string, string | string[] | undefined>;
6
7
  export interface QrSignIn {
@@ -9,6 +10,22 @@ export interface QrSignIn {
9
10
  expiresIn: number;
10
11
  interval: number;
11
12
  }
13
+ /** What each step of the modal answers; see `NextAuth.startFreshSignIn`. Never a redirect. */
14
+ export type FreshResult = {
15
+ ok: true;
16
+ } | {
17
+ ok: false;
18
+ error: SignInError | 'other_person' | 'invalid_code';
19
+ } | {
20
+ ok: false;
21
+ secondFactor: {
22
+ token: string;
23
+ methods: 'totp'[];
24
+ };
25
+ } | {
26
+ ok: false;
27
+ fallbackUrl: string;
28
+ };
12
29
  export interface NextAuth {
13
30
  auth: Auth;
14
31
  /** `export const { GET, POST } = handlers` from app/auth/[...auth]/route.ts. */
@@ -41,6 +58,62 @@ export interface NextAuth {
41
58
  next?: string;
42
59
  searchParams?: SearchParams | Promise<SearchParams>;
43
60
  }) => Promise<User>;
61
+ /**
62
+ * `requireFreshUser` for a server action: the user when they authenticated
63
+ * within `maxAge` seconds, otherwise an `ask` for the action to return to
64
+ * its client, which opens the sign-in modal (`withFreshSignIn` in
65
+ * `@wtfalch/auth/fresh`). Never redirects. Throws `AuthError('unauthenticated')`
66
+ * for nobody, as `startPasskeyRegistration` does.
67
+ */
68
+ freshUserOrAsk: (maxAge: MaxAge) => Promise<{
69
+ fresh: true;
70
+ user: User;
71
+ } | {
72
+ fresh: false;
73
+ ask: FreshSignInAsk;
74
+ }>;
75
+ /**
76
+ * Step 1 of the modal, for a server action. Starts a `prompt=login` auth
77
+ * request for the signed-in person and keeps its PKCE transaction in the
78
+ * sealed transaction cookie. `fallbackUrl` is where `requireFreshUser` would
79
+ * have sent them: the modal cannot be used, so the app does what it did
80
+ * before. `next` is where that fallback returns to.
81
+ */
82
+ startFreshSignIn: (opts?: {
83
+ next?: string;
84
+ }) => Promise<{
85
+ ok: true;
86
+ authRequestId: string;
87
+ loginHint: string;
88
+ passkey: boolean;
89
+ } | {
90
+ ok: false;
91
+ fallbackUrl: string;
92
+ }>;
93
+ /** The password step. On `ok` the session cookie is already replaced; nothing redirects. */
94
+ finishFreshSignIn: (input: {
95
+ authRequestId: string;
96
+ password: string;
97
+ }) => Promise<FreshResult>;
98
+ /** A passkey challenge: answer it with `@wtfalch/auth/passkey`'s `getPasskey`, then `finishFreshPasskey`. */
99
+ startFreshPasskey: (input: {
100
+ authRequestId: string;
101
+ }) => Promise<{
102
+ ok: true;
103
+ options: unknown;
104
+ } | Exclude<FreshResult, {
105
+ ok: true;
106
+ }>>;
107
+ finishFreshPasskey: (input: {
108
+ authRequestId: string;
109
+ credential: unknown;
110
+ }) => Promise<FreshResult>;
111
+ /** The code step after a `secondFactor` result. */
112
+ verifyFreshSecondFactor: (input: {
113
+ authRequestId: string;
114
+ token: string;
115
+ code: string;
116
+ }) => Promise<FreshResult>;
44
117
  /**
45
118
  * For the app's login and register pages: the auth request the issuer sent
46
119
  * the browser with, or a redirect that starts one. Pass the page's searchParams.
@@ -184,4 +257,4 @@ export interface NextAuth {
184
257
  }>;
185
258
  }
186
259
  export declare function nextAuth(options: AuthOptions): NextAuth;
187
- export type { Auth, AuthEvent, AuthOptions, AuthRequest, ChangePasswordResult, DeletePasskeyResult, EmailChangeResult, GateRules, Intent, MaxAge, NewUser, Passkey, PasskeyRegistrationResult, PasskeyRegistrationStart, PasskeySignInStart, SetNewPasswordError, SetNewPasswordResult, Prompt, QrPollStatus, SessionRecord, User, };
260
+ export type { FreshSignInAsk, Auth, AuthEvent, AuthOptions, AuthRequest, ChangePasswordResult, DeletePasskeyResult, EmailChangeResult, GateRules, Intent, MaxAge, NewUser, Passkey, PasskeyRegistrationResult, PasskeyRegistrationStart, PasskeySignInStart, SetNewPasswordError, SetNewPasswordResult, Prompt, QrPollStatus, SessionRecord, User, };
package/dist/next.js CHANGED
@@ -163,6 +163,91 @@ export function nextAuth(options) {
163
163
  }
164
164
  return new Request('http://localhost/', { headers: requestHeaders });
165
165
  };
166
+ const freshUserOrAsk = async (maxAge) => {
167
+ const store = await cookies();
168
+ const { user, unavailable } = await auth.readCookie(store.get(auth.sessionCookieName)?.value, {
169
+ refresh: false,
170
+ });
171
+ if (unavailable)
172
+ throw new AuthError('unavailable', 'Sign-in is unavailable for this workspace');
173
+ if (!user)
174
+ throw new AuthError('unauthenticated', 'sign in to continue');
175
+ if (auth.fresh(user, maxAge))
176
+ return { fresh: true, user };
177
+ return { fresh: false, ask: { kind: 'fresh-sign-in', maxAge } };
178
+ };
179
+ // Where requireFreshUser would send them. `max_age=0` because the modal does not know the bound the action wanted.
180
+ const freshFallback = (next) => auth.startUrl(marked(next), 'login', undefined, 0);
181
+ const setAll = async (set) => {
182
+ const store = await cookies();
183
+ for (const c of set)
184
+ store.set(c.name, c.value, c.attributes);
185
+ };
186
+ const freshResult = async (outcome) => {
187
+ await setAll(outcome.cookies);
188
+ if (outcome.ok)
189
+ return { ok: true };
190
+ if ('hosted' in outcome)
191
+ return { ok: false, fallbackUrl: freshFallback(outcome.next) };
192
+ if ('secondFactor' in outcome)
193
+ return { ok: false, secondFactor: outcome.secondFactor };
194
+ return { ok: false, error: outcome.error };
195
+ };
196
+ // A server action's argument is whatever the client sent: every field is checked where it is read.
197
+ const text = (value, max) => typeof value === 'string' && value.length > 0 && value.length <= max ? value : null;
198
+ const refused = { ok: false, error: 'request' };
199
+ const startFreshSignIn = async (opts) => {
200
+ const next = typeof opts?.next === 'string' ? opts.next : undefined;
201
+ const result = await auth.startFreshSignIn(await requestFromCookies(), { next });
202
+ await setAll(result.cookies);
203
+ if (!result.ok)
204
+ return { ok: false, fallbackUrl: freshFallback(next) };
205
+ return {
206
+ ok: true,
207
+ authRequestId: result.authRequestId,
208
+ loginHint: result.loginHint,
209
+ passkey: result.passkey,
210
+ };
211
+ };
212
+ const finishFreshSignIn = async (input) => {
213
+ const authRequestId = text(input?.authRequestId, 256);
214
+ const password = text(input?.password, 1024);
215
+ if (!authRequestId || !password)
216
+ return refused;
217
+ return freshResult(await auth.finishFreshSignIn(await requestFromCookies(), { authRequestId, password }));
218
+ };
219
+ const startFreshPasskey = async (input) => {
220
+ const authRequestId = text(input?.authRequestId, 256);
221
+ if (!authRequestId)
222
+ return refused;
223
+ const result = await auth.startFreshPasskey(await requestFromCookies(), { authRequestId });
224
+ if (result.ok) {
225
+ await setAll(result.cookies);
226
+ return { ok: true, options: result.options };
227
+ }
228
+ return freshResult(result);
229
+ };
230
+ const finishFreshPasskey = async (input) => {
231
+ const authRequestId = text(input?.authRequestId, 256);
232
+ if (!authRequestId || typeof input.credential !== 'object' || input.credential === null)
233
+ return refused;
234
+ return freshResult(await auth.finishFreshPasskey(await requestFromCookies(), {
235
+ authRequestId,
236
+ credential: input.credential,
237
+ }));
238
+ };
239
+ const verifyFreshSecondFactor = async (input) => {
240
+ const authRequestId = text(input?.authRequestId, 256);
241
+ const token = text(input?.token, 2048);
242
+ const code = text(input?.code, 32);
243
+ if (!authRequestId || !token || !code)
244
+ return refused;
245
+ return freshResult(await auth.verifyFreshSecondFactor(await requestFromCookies(), {
246
+ authRequestId,
247
+ token,
248
+ code,
249
+ }));
250
+ };
166
251
  const startPasskeyRegistration = async () => auth.startPasskeyRegistration(await requestFromCookies());
167
252
  const finishPasskeyRegistration = async (input) => auth.finishPasskeyRegistration(await requestFromCookies(), input);
168
253
  const startEmailChange = async (email) => auth.startEmailChange(await requestFromCookies(), email);
@@ -281,6 +366,12 @@ export function nextAuth(options) {
281
366
  getUser,
282
367
  requireUser,
283
368
  requireFreshUser,
369
+ freshUserOrAsk,
370
+ startFreshSignIn,
371
+ finishFreshSignIn,
372
+ startFreshPasskey,
373
+ finishFreshPasskey,
374
+ verifyFreshSecondFactor,
284
375
  authRequest,
285
376
  signIn,
286
377
  setNewPassword,
package/dist/preview.d.ts CHANGED
@@ -1,16 +1,20 @@
1
+ import type { Auth, User } from './auth.js';
1
2
  /** The cookie that selects the idle slot at the balancer. */
2
3
  export declare const PREVIEW_COOKIE = "bg_preview";
3
4
  /** Where the balancer's forwardAuth asks whether the request may see the idle slot. */
4
5
  export declare const PREVIEW_CHECK_PATH = "/api/foundry/preview-check";
5
6
  /** Where the app sets (POST) and clears (DELETE) the preview cookie. */
6
7
  export declare const PREVIEW_PATH = "/api/foundry/preview";
7
- export interface PreviewRoutesOptions<S> {
8
- /** The app's own session reader (for example `auth.read`). Null means signed out. */
9
- readSession: (request: Request) => Promise<S | null>;
8
+ export interface PreviewRoutesOptions {
9
+ /**
10
+ * The app's auth instance. The session is read through it (without
11
+ * refreshing, since these routes set no session cookie), and the app's origin
12
+ * and the cookie's `Secure` rule come from its `appUrl`, read on the first
13
+ * request so importing this module at build time needs no environment.
14
+ */
15
+ auth: Pick<Auth, 'read' | 'appOrigin' | 'cookiesSecure'>;
10
16
  /** Whether this session holds `releases:preview` in the app's production authz. */
11
- mayPreview: (session: S) => Promise<boolean>;
12
- /** The app's own origin, the one `appUrl` names. Required: the trusted origin never comes from the request. */
13
- appOrigin: string;
17
+ mayPreview: (user: User) => Promise<boolean>;
14
18
  }
15
19
  export interface PreviewRoutes {
16
20
  /** GET `PREVIEW_CHECK_PATH`: 200 allowed, 401 no session, 403 otherwise. */
@@ -21,4 +25,4 @@ export interface PreviewRoutes {
21
25
  clear: (request: Request) => Promise<Response>;
22
26
  }
23
27
  /** Route handlers for the idle preview: the check the balancer calls, and the cookie the person sets and clears. */
24
- export declare function createPreviewRoutes<S>(options: PreviewRoutesOptions<S>): PreviewRoutes;
28
+ export declare function createPreviewRoutes(options: PreviewRoutesOptions): PreviewRoutes;
package/dist/preview.js CHANGED
@@ -1,5 +1,6 @@
1
- import { setCookie } from './cookies.js';
1
+ import { clearCookie, setCookie } from './cookies.js';
2
2
  import { isSameOrigin } from './redirect.js';
3
+ import { NO_STORE } from './util.js';
3
4
  /** The cookie that selects the idle slot at the balancer. */
4
5
  export const PREVIEW_COOKIE = 'bg_preview';
5
6
  /** Where the balancer's forwardAuth asks whether the request may see the idle slot. */
@@ -8,52 +9,47 @@ export const PREVIEW_CHECK_PATH = '/api/foundry/preview-check';
8
9
  export const PREVIEW_PATH = '/api/foundry/preview';
9
10
  /** Eight hours. */
10
11
  const PREVIEW_MAX_AGE = 28_800;
11
- const NO_STORE = { 'cache-control': 'no-store' };
12
12
  function reply(status, extra = {}) {
13
13
  return new Response(null, { status, headers: { ...NO_STORE, ...extra } });
14
14
  }
15
15
  /** Route handlers for the idle preview: the check the balancer calls, and the cookie the person sets and clears. */
16
16
  export function createPreviewRoutes(options) {
17
- // 200 when allowed; otherwise the refusing status. A thrown reader or
18
- // permission check is a refusal, logged by error name only so no session
19
- // data or message reaches the log.
17
+ // 'allowed', or the refusing status. A thrown reader or permission check is
18
+ // a refusal, logged by error name only so no session data or message
19
+ // reaches the log.
20
20
  const decide = async (request) => {
21
21
  try {
22
- const session = await options.readSession(request);
23
- if (session === null)
22
+ const { user } = await options.auth.read(request, { refresh: false });
23
+ if (user === null)
24
24
  return 401;
25
- return (await options.mayPreview(session)) ? 200 : 403;
25
+ return (await options.mayPreview(user)) ? 'allowed' : 403;
26
26
  }
27
27
  catch (error) {
28
28
  console.error(`@wtfalch/auth: preview check failed (${error instanceof Error ? error.name : 'Error'})`);
29
29
  return 403;
30
30
  }
31
31
  };
32
- // Normalised once so a malformed value throws at setup, and case or a
33
- // trailing slash cannot change `secure` or the same-origin compare.
34
- const appOrigin = new URL(options.appOrigin).origin;
35
- const secure = appOrigin.startsWith('https:');
36
- const sameOrigin = (request) => {
37
- if (isSameOrigin(request, appOrigin))
38
- return true;
39
- console.error('@wtfalch/auth: preview request refused (cross-origin)');
40
- return false;
41
- };
32
+ // Read per request: the auth instance resolves its options once, on first
33
+ // use, so nothing is read while this module is being imported.
34
+ const sameOrigin = (request) => isSameOrigin(request, options.auth.appOrigin);
42
35
  return {
43
- check: async (request) => reply(await decide(request)),
36
+ check: async (request) => {
37
+ const decision = await decide(request);
38
+ return reply(decision === 'allowed' ? 200 : decision);
39
+ },
44
40
  set: async (request) => {
45
41
  if (!sameOrigin(request))
46
42
  return reply(403);
47
- const status = await decide(request);
48
- if (status !== 200)
49
- return reply(status);
50
- const cookie = setCookie(PREVIEW_COOKIE, '1', PREVIEW_MAX_AGE, secure);
43
+ const decision = await decide(request);
44
+ if (decision !== 'allowed')
45
+ return reply(decision);
46
+ const cookie = setCookie(PREVIEW_COOKIE, '1', PREVIEW_MAX_AGE, options.auth.cookiesSecure);
51
47
  return reply(204, { 'set-cookie': cookie.header });
52
48
  },
53
49
  clear: async (request) => {
54
50
  if (!sameOrigin(request))
55
51
  return reply(403);
56
- const cookie = setCookie(PREVIEW_COOKIE, '', 0, secure);
52
+ const cookie = clearCookie(PREVIEW_COOKIE, options.auth.cookiesSecure);
57
53
  return reply(204, { 'set-cookie': cookie.header });
58
54
  },
59
55
  };
@@ -7,4 +7,12 @@ export declare function safeNextPath(next: string | null | undefined, fallback:
7
7
  * because the person can still type their address at the issuer.
8
8
  */
9
9
  export declare function safeLoginHint(raw: string | null | undefined): string | undefined;
10
+ /**
11
+ * Whether a state-changing request came from `origin` (the app's own, from
12
+ * `appUrl`, never from the request). A browser's `Origin` header must equal it
13
+ * exactly; with no `Origin`, `Sec-Fetch-Site: same-origin` is accepted. Both
14
+ * headers are set by the browser and cannot be forged by a page, but a non-browser
15
+ * client can send anything, so this is a CSRF defence, not authentication. A
16
+ * request with neither header is refused. Refuses silently: the caller answers.
17
+ */
10
18
  export declare function isSameOrigin(request: Request, origin: string): boolean;
package/dist/redirect.js CHANGED
@@ -33,6 +33,14 @@ export function safeLoginHint(raw) {
33
33
  return undefined;
34
34
  return /^[^\s@\p{Cc}]+@[^\s@\p{Cc}]+\.[^\s@\p{Cc}]+$/u.test(raw) ? raw : undefined;
35
35
  }
36
+ /**
37
+ * Whether a state-changing request came from `origin` (the app's own, from
38
+ * `appUrl`, never from the request). A browser's `Origin` header must equal it
39
+ * exactly; with no `Origin`, `Sec-Fetch-Site: same-origin` is accepted. Both
40
+ * headers are set by the browser and cannot be forged by a page, but a non-browser
41
+ * client can send anything, so this is a CSRF defence, not authentication. A
42
+ * request with neither header is refused. Refuses silently: the caller answers.
43
+ */
36
44
  export function isSameOrigin(request, origin) {
37
45
  const from = request.headers.get('origin');
38
46
  if (from)
package/dist/util.d.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  /** Helpers shared by the server and browser entry points. No Node-only imports: the browser bundle uses this file too. */
2
2
  /** ZITADEL's resource-owner (organisation) claim, on a JWT and on an introspection response alike. */
3
3
  export declare const ORG_CLAIM = "urn:zitadel:iam:user:resourceowner:id";
4
+ /** Headers for a response no cache may keep. */
5
+ export declare const NO_STORE: {
6
+ 'Cache-Control': string;
7
+ };
4
8
  /** How long an SDK request to the broker or issuer may take before it is abandoned. */
5
9
  export declare const FETCH_TIMEOUT_MS = 15000;
6
10
  /**
package/dist/util.js CHANGED
@@ -1,6 +1,8 @@
1
1
  /** Helpers shared by the server and browser entry points. No Node-only imports: the browser bundle uses this file too. */
2
2
  /** ZITADEL's resource-owner (organisation) claim, on a JWT and on an introspection response alike. */
3
3
  export const ORG_CLAIM = 'urn:zitadel:iam:user:resourceowner:id';
4
+ /** Headers for a response no cache may keep. */
5
+ export const NO_STORE = { 'Cache-Control': 'no-store' };
4
6
  /** How long an SDK request to the broker or issuer may take before it is abandoned. */
5
7
  export const FETCH_TIMEOUT_MS = 15_000;
6
8
  /**
@@ -0,0 +1,79 @@
1
+ import type { User } from './auth.js';
2
+ /**
3
+ * Client for the broker's customer-workspace routes (ADR 0004). The app keeps
4
+ * the person's session and tells the broker who is acting; the key never
5
+ * leaves this module except in the `Authorization` header.
6
+ */
7
+ export interface WorkspacesEnv {
8
+ WORKSPACES_URL?: string;
9
+ WORKSPACES_API_KEY?: string;
10
+ AUTH_ORGANIZATION_ID?: string;
11
+ [name: string]: string | undefined;
12
+ }
13
+ export interface Workspace {
14
+ id: string;
15
+ name: string;
16
+ slug: string;
17
+ }
18
+ export interface WorkspaceMembership {
19
+ id: string;
20
+ name: string;
21
+ role: string;
22
+ }
23
+ export interface WorkspaceMember {
24
+ sub: string;
25
+ email: string;
26
+ name: string | null;
27
+ role: string;
28
+ }
29
+ export interface WorkspaceInvitation {
30
+ id: string;
31
+ email: string;
32
+ role: string;
33
+ createdAt: string;
34
+ expiresAt: string;
35
+ }
36
+ export type WorkspacesIdentity = Pick<User, 'id' | 'email' | 'emailVerified'>;
37
+ export declare const WORKSPACES_TIMEOUT_MS = 10000;
38
+ export declare class WorkspacesError extends Error {
39
+ readonly status: number;
40
+ /** The broker's `error` code, or `unavailable` when it did not answer in the expected shape. */
41
+ readonly code: string;
42
+ constructor(status: number, code: string, message: string);
43
+ }
44
+ export interface Workspaces {
45
+ create(input: {
46
+ name: string;
47
+ owner: WorkspacesIdentity;
48
+ }): Promise<Workspace>;
49
+ listFor(user: Pick<User, 'id'>): Promise<WorkspaceMembership[]>;
50
+ invite(workspaceId: string, input: {
51
+ email: string;
52
+ role: string;
53
+ actor: Pick<User, 'id'>;
54
+ }): Promise<{
55
+ id: string;
56
+ }>;
57
+ accept(token: string, user: WorkspacesIdentity): Promise<{
58
+ workspaceId: string;
59
+ role: string;
60
+ }>;
61
+ setRole(workspaceId: string, memberSub: string, input: {
62
+ role: string;
63
+ actor: Pick<User, 'id'>;
64
+ }): Promise<void>;
65
+ remove(workspaceId: string, memberSub: string, actor: Pick<User, 'id'>): Promise<void>;
66
+ /** The person's role in the workspace, or null when they are not a member. */
67
+ access(workspaceId: string, user: Pick<User, 'id'>): Promise<string | null>;
68
+ /** Who is in the workspace. Any member may ask. */
69
+ members(workspaceId: string, actor: Pick<User, 'id'>): Promise<WorkspaceMember[]>;
70
+ /** Invitations not yet accepted or revoked. Owner only. */
71
+ invitations(workspaceId: string, actor: Pick<User, 'id'>): Promise<WorkspaceInvitation[]>;
72
+ /** Owner only. The emailed link stops working. */
73
+ revokeInvitation(workspaceId: string, invitationId: string, actor: Pick<User, 'id'>): Promise<void>;
74
+ /** Owner only. */
75
+ rename(workspaceId: string, name: string, actor: Pick<User, 'id'>): Promise<void>;
76
+ /** Owner only. The workspace leaves every list and later calls fail with status 410. */
77
+ archive(workspaceId: string, actor: Pick<User, 'id'>): Promise<void>;
78
+ }
79
+ export declare function workspaces(env?: WorkspacesEnv): Workspaces;
@@ -0,0 +1,137 @@
1
+ export const WORKSPACES_TIMEOUT_MS = 10_000;
2
+ export class WorkspacesError extends Error {
3
+ status;
4
+ /** The broker's `error` code, or `unavailable` when it did not answer in the expected shape. */
5
+ code;
6
+ constructor(status, code, message) {
7
+ super(message);
8
+ this.name = 'WorkspacesError';
9
+ this.status = status;
10
+ this.code = code;
11
+ }
12
+ }
13
+ function need(env, name) {
14
+ const value = env[name]?.trim();
15
+ if (!value)
16
+ throw new Error(`@wtfalch/auth/workspaces: ${name} is not set.`);
17
+ return value;
18
+ }
19
+ function requireEmail(user) {
20
+ if (!user.email) {
21
+ throw new WorkspacesError(0, 'invalid', 'The signed-in person has no email address.');
22
+ }
23
+ return user.email;
24
+ }
25
+ export function workspaces(env = process.env) {
26
+ const base = need(env, 'WORKSPACES_URL').replace(/\/+$/, '');
27
+ const key = need(env, 'WORKSPACES_API_KEY');
28
+ const organizationId = need(env, 'AUTH_ORGANIZATION_ID');
29
+ const root = `${base}/apps/${encodeURIComponent(organizationId)}/v1`;
30
+ async function call(method, path, options = {}) {
31
+ const url = new URL(root + path);
32
+ for (const [name, value] of Object.entries(options.query ?? {})) {
33
+ url.searchParams.set(name, value);
34
+ }
35
+ const headers = {
36
+ authorization: `Bearer ${key}`,
37
+ accept: 'application/json',
38
+ };
39
+ if (options.body !== undefined)
40
+ headers['content-type'] = 'application/json';
41
+ let response;
42
+ try {
43
+ response = await fetch(url, {
44
+ method,
45
+ headers,
46
+ body: options.body === undefined ? undefined : JSON.stringify(options.body),
47
+ signal: AbortSignal.timeout(WORKSPACES_TIMEOUT_MS),
48
+ });
49
+ }
50
+ catch (error) {
51
+ const timedOut = error instanceof Error && error.name === 'TimeoutError';
52
+ throw new WorkspacesError(0, 'unavailable', timedOut
53
+ ? `The workspaces service did not answer within ${WORKSPACES_TIMEOUT_MS / 1000} seconds.`
54
+ : 'The workspaces service could not be reached.');
55
+ }
56
+ let payload;
57
+ try {
58
+ payload = await response.json();
59
+ }
60
+ catch {
61
+ payload = undefined;
62
+ }
63
+ if (!response.ok) {
64
+ const fields = (payload ?? {});
65
+ throw new WorkspacesError(response.status, typeof fields.error === 'string' ? fields.error : 'unavailable', typeof fields.message === 'string'
66
+ ? fields.message.split(key).join('[redacted]')
67
+ : `The workspaces service answered ${response.status}.`);
68
+ }
69
+ return payload;
70
+ }
71
+ const segment = encodeURIComponent;
72
+ return {
73
+ async create({ name, owner }) {
74
+ const body = { name, ownerSub: owner.id, ownerEmail: requireEmail(owner) };
75
+ return (await call('POST', '/workspaces', { body }));
76
+ },
77
+ async listFor(user) {
78
+ return (await call('GET', '/workspaces', {
79
+ query: { sub: user.id },
80
+ }));
81
+ },
82
+ async invite(workspaceId, { email, role, actor }) {
83
+ const body = { email, role, actorSub: actor.id };
84
+ return (await call('POST', `/workspaces/${segment(workspaceId)}/invitations`, {
85
+ body,
86
+ }));
87
+ },
88
+ async accept(token, user) {
89
+ const body = {
90
+ token,
91
+ sub: user.id,
92
+ email: requireEmail(user),
93
+ emailVerified: user.emailVerified,
94
+ };
95
+ return (await call('POST', '/invitations/accept', { body }));
96
+ },
97
+ async setRole(workspaceId, memberSub, { role, actor }) {
98
+ await call('PUT', `/workspaces/${segment(workspaceId)}/members/${segment(memberSub)}`, {
99
+ body: { role, actorSub: actor.id },
100
+ });
101
+ },
102
+ async remove(workspaceId, memberSub, actor) {
103
+ await call('DELETE', `/workspaces/${segment(workspaceId)}/members/${segment(memberSub)}`, {
104
+ query: { actorSub: actor.id },
105
+ });
106
+ },
107
+ async access(workspaceId, user) {
108
+ const answer = (await call('GET', `/workspaces/${segment(workspaceId)}/access`, {
109
+ query: { sub: user.id },
110
+ }));
111
+ return answer.role;
112
+ },
113
+ async members(workspaceId, actor) {
114
+ return (await call('GET', `/workspaces/${segment(workspaceId)}/members`, {
115
+ query: { actorSub: actor.id },
116
+ }));
117
+ },
118
+ async invitations(workspaceId, actor) {
119
+ return (await call('GET', `/workspaces/${segment(workspaceId)}/invitations`, {
120
+ query: { actorSub: actor.id },
121
+ }));
122
+ },
123
+ async revokeInvitation(workspaceId, invitationId, actor) {
124
+ await call('DELETE', `/workspaces/${segment(workspaceId)}/invitations/${segment(invitationId)}`, { query: { actorSub: actor.id } });
125
+ },
126
+ async rename(workspaceId, name, actor) {
127
+ await call('PATCH', `/workspaces/${segment(workspaceId)}`, {
128
+ body: { name, actorSub: actor.id },
129
+ });
130
+ },
131
+ async archive(workspaceId, actor) {
132
+ await call('POST', `/workspaces/${segment(workspaceId)}/archive`, {
133
+ body: { actorSub: actor.id },
134
+ });
135
+ },
136
+ };
137
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/auth",
3
- "version": "0.32.0",
3
+ "version": "0.34.0",
4
4
  "description": "Sign in against a self-hosted ZITADEL issuer: 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
+ "./fresh": {
26
+ "types": "./dist/fresh.d.ts",
27
+ "default": "./dist/fresh.js"
28
+ },
25
29
  "./passkey": {
26
30
  "types": "./dist/passkey-browser.d.ts",
27
31
  "default": "./dist/passkey-browser.js"
@@ -65,6 +69,10 @@
65
69
  "./desktop": {
66
70
  "types": "./dist/desktop.d.ts",
67
71
  "default": "./dist/desktop.js"
72
+ },
73
+ "./workspaces": {
74
+ "types": "./dist/workspaces.d.ts",
75
+ "default": "./dist/workspaces.js"
68
76
  }
69
77
  },
70
78
  "sideEffects": false,
@@ -103,10 +111,12 @@
103
111
  "@cloudflare/vitest-pool-workers": "0.23.0",
104
112
  "@electric-sql/pglite": "^0.5.8",
105
113
  "@types/node": "^22",
114
+ "@types/pg": "^8.23.1",
106
115
  "@types/qrcode": "^1.5.6",
107
116
  "@types/react": "^19",
108
117
  "@wtfalch/auth-broker": "workspace:*",
109
- "next": "^16.4.0",
118
+ "next": "^16.4.2",
119
+ "pg": "8.16.3",
110
120
  "playwright-core": "^1.62.1",
111
121
  "react": "^19",
112
122
  "typescript": "^5.9.0",