@wtfalch/auth 0.31.0 → 0.33.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.
@@ -0,0 +1,56 @@
1
+ import { clearCookie, setCookie } from './cookies.js';
2
+ import { isSameOrigin } from './redirect.js';
3
+ import { NO_STORE } from './util.js';
4
+ /** The cookie that selects the idle slot at the balancer. */
5
+ export const PREVIEW_COOKIE = 'bg_preview';
6
+ /** Where the balancer's forwardAuth asks whether the request may see the idle slot. */
7
+ export const PREVIEW_CHECK_PATH = '/api/foundry/preview-check';
8
+ /** Where the app sets (POST) and clears (DELETE) the preview cookie. */
9
+ export const PREVIEW_PATH = '/api/foundry/preview';
10
+ /** Eight hours. */
11
+ const PREVIEW_MAX_AGE = 28_800;
12
+ function reply(status, extra = {}) {
13
+ return new Response(null, { status, headers: { ...NO_STORE, ...extra } });
14
+ }
15
+ /** Route handlers for the idle preview: the check the balancer calls, and the cookie the person sets and clears. */
16
+ export function createPreviewRoutes(options) {
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
+ const decide = async (request) => {
21
+ try {
22
+ const { user } = await options.auth.read(request, { refresh: false });
23
+ if (user === null)
24
+ return 401;
25
+ return (await options.mayPreview(user)) ? 'allowed' : 403;
26
+ }
27
+ catch (error) {
28
+ console.error(`@wtfalch/auth: preview check failed (${error instanceof Error ? error.name : 'Error'})`);
29
+ return 403;
30
+ }
31
+ };
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);
35
+ return {
36
+ check: async (request) => {
37
+ const decision = await decide(request);
38
+ return reply(decision === 'allowed' ? 200 : decision);
39
+ },
40
+ set: async (request) => {
41
+ if (!sameOrigin(request))
42
+ return reply(403);
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);
47
+ return reply(204, { 'set-cookie': cookie.header });
48
+ },
49
+ clear: async (request) => {
50
+ if (!sameOrigin(request))
51
+ return reply(403);
52
+ const cookie = clearCookie(PREVIEW_COOKIE, options.auth.cookiesSecure);
53
+ return reply(204, { 'set-cookie': cookie.header });
54
+ },
55
+ };
56
+ }
@@ -7,3 +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
+ */
18
+ export declare function isSameOrigin(request: Request, origin: string): boolean;
package/dist/redirect.js CHANGED
@@ -33,3 +33,17 @@ 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
+ */
44
+ export function isSameOrigin(request, origin) {
45
+ const from = request.headers.get('origin');
46
+ if (from)
47
+ return from === origin;
48
+ return request.headers.get('sec-fetch-site') === 'same-origin';
49
+ }
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.31.0",
3
+ "version": "0.33.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",
@@ -65,6 +65,10 @@
65
65
  "./desktop": {
66
66
  "types": "./dist/desktop.d.ts",
67
67
  "default": "./dist/desktop.js"
68
+ },
69
+ "./workspaces": {
70
+ "types": "./dist/workspaces.d.ts",
71
+ "default": "./dist/workspaces.js"
68
72
  }
69
73
  },
70
74
  "sideEffects": false,
@@ -103,10 +107,12 @@
103
107
  "@cloudflare/vitest-pool-workers": "0.23.0",
104
108
  "@electric-sql/pglite": "^0.5.8",
105
109
  "@types/node": "^22",
110
+ "@types/pg": "^8.23.1",
106
111
  "@types/qrcode": "^1.5.6",
107
112
  "@types/react": "^19",
108
113
  "@wtfalch/auth-broker": "workspace:*",
109
- "next": "^16.4.0",
114
+ "next": "^16.4.2",
115
+ "pg": "8.16.3",
110
116
  "playwright-core": "^1.62.1",
111
117
  "react": "^19",
112
118
  "typescript": "^5.9.0",