@wtfalch/auth 0.6.0 → 0.7.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/broker.d.ts CHANGED
@@ -89,11 +89,14 @@ export declare class Broker {
89
89
  /**
90
90
  * What the service routes this key to. A suspended binding's key is refused.
91
91
  * Bounded, because every namespace request waits on this answer when it is due.
92
+ * `revoked` names the organization's recently deactivated people. It is absent
93
+ * from an older service, or while the service cannot ask the issuer.
92
94
  */
93
95
  binding(): Promise<{
94
96
  organizationId: string;
95
97
  clientIds: string[];
96
98
  origins: string[];
99
+ revoked?: string[];
97
100
  }>;
98
101
  verifyEmail(userId: string, code: string): Promise<unknown>;
99
102
  /** A public client revoking its own token: no key, no service. */
package/dist/broker.js CHANGED
@@ -99,6 +99,8 @@ export class Broker {
99
99
  /**
100
100
  * What the service routes this key to. A suspended binding's key is refused.
101
101
  * Bounded, because every namespace request waits on this answer when it is due.
102
+ * `revoked` names the organization's recently deactivated people. It is absent
103
+ * from an older service, or while the service cannot ask the issuer.
102
104
  */
103
105
  binding() {
104
106
  return this.call('GET', '/binding', {}, AbortSignal.timeout(5_000));
@@ -1,7 +1,7 @@
1
1
  import { jwtDecrypt } from 'jose';
2
2
  import { resolveOptions } from './config.js';
3
3
  import { cookieFrom, openTransaction, transactionCookieName } from './cookies.js';
4
- import { parseNamespaceRegistry, } from './namespaces.js';
4
+ import { isWebService, parseNamespaceRegistry, } from './namespaces.js';
5
5
  /** Resolve a shared callback from its authenticated transaction, never a URL
6
6
  * namespace hint. The second open checks the entire current registry binding. */
7
7
  export async function resolveNamespaceCallback(input) {
@@ -9,12 +9,16 @@ export async function resolveNamespaceCallback(input) {
9
9
  const registry = parseNamespaceRegistry(input.registry);
10
10
  const callback = new URL(`${input.basePath ?? '/auth'}/callback`, input.appOrigin).href;
11
11
  const candidates = registry.namespaces.filter((n) => n.status === 'active' &&
12
- n.services.some((s) => s.serviceId === input.serviceId &&
12
+ n.services
13
+ .filter(isWebService)
14
+ .some((s) => s.serviceId === input.serviceId &&
13
15
  s.deploymentId === input.deploymentId &&
14
16
  s.appOrigin === input.appOrigin &&
15
17
  s.redirectUris.includes(callback)));
16
18
  const optionsFor = (namespace) => {
17
- const service = namespace.services.find((s) => s.serviceId === input.serviceId && s.deploymentId === input.deploymentId);
19
+ const service = namespace.services
20
+ .filter(isWebService)
21
+ .find((s) => s.serviceId === input.serviceId && s.deploymentId === input.deploymentId);
18
22
  if (!service)
19
23
  throw new Error('Missing service');
20
24
  const selection = {
@@ -35,15 +35,40 @@ export function namespaceSessions(deps) {
35
35
  let status = null;
36
36
  let retryAt = 0;
37
37
  let pending = null;
38
+ // People whose sessions this instance has already ended for the current
39
+ // deactivation. Someone who leaves the list, by reactivation or by ageing
40
+ // out of the service's window, is forgotten, so a second deactivation
41
+ // ends their sessions again.
42
+ const ended = new Set();
43
+ const endDeactivated = async (revoked) => {
44
+ if (!Array.isArray(revoked))
45
+ return;
46
+ const current = new Set(revoked.filter((s) => typeof s === 'string' && !!s));
47
+ for (const subject of ended)
48
+ if (!current.has(subject))
49
+ ended.delete(subject);
50
+ for (const subject of current) {
51
+ if (ended.has(subject))
52
+ continue;
53
+ await revokeSubject(subject);
54
+ ended.add(subject);
55
+ }
56
+ };
38
57
  const askService = async () => {
39
58
  const namespace = options().namespace;
40
59
  if (!namespace)
41
60
  return false;
42
61
  try {
43
62
  const routed = await deps.broker().binding();
44
- return (routed.organizationId === namespace.organizationId &&
63
+ const active = routed.organizationId === namespace.organizationId &&
45
64
  routed.clientIds.includes(namespace.clientId) &&
46
- routed.origins.includes(namespace.appOrigin));
65
+ routed.origins.includes(namespace.appOrigin);
66
+ // Deactivated anywhere, by any service or in the issuer's console: their
67
+ // sessions here end before this answer is used, so within the same bound
68
+ // as a suspension. Only a list about this binding's own organization counts.
69
+ if (routed.organizationId === namespace.organizationId)
70
+ await endDeactivated(routed.revoked);
71
+ return active;
47
72
  }
48
73
  catch (error) {
49
74
  // Suspension removes the binding's key, so the service no longer knows it.
@@ -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;
@@ -19,11 +21,42 @@ export interface NamespaceService {
19
21
  credentialRef: string;
20
22
  registration: boolean;
21
23
  }
24
+ /**
25
+ * Installed software a person signs in to by approving on another device: a
26
+ * public device-code client with no secret and no redirect URIs.
27
+ */
28
+ export interface NamespaceNativeService {
29
+ kind: 'native';
30
+ id: string;
31
+ serviceId: string;
32
+ deploymentId: string;
33
+ }
34
+ /**
35
+ * A resource server that checks the namespace's tokens at the introspection
36
+ * endpoint. Its client secret is stored under `credentialRef`, never here.
37
+ */
38
+ export interface NamespaceApiService {
39
+ kind: 'api';
40
+ id: string;
41
+ serviceId: string;
42
+ deploymentId: string;
43
+ credentialRef: string;
44
+ }
45
+ /** Every client of one namespace shares its accounts: one sign-in across all of them. */
46
+ export type NamespaceBinding = NamespaceService | NamespaceNativeService | NamespaceApiService;
47
+ export declare function isWebService<T extends NamespaceBinding>(binding: T): binding is Extract<T, NamespaceService>;
22
48
  export interface NamespaceSpec {
23
49
  id: string;
24
50
  displayName: string;
25
51
  revision: number;
26
52
  status: Exclude<NamespaceStatus, 'failed'>;
53
+ /**
54
+ * `invitation`: nobody signs themselves up. People arrive by invitation,
55
+ * whether from the organization's admin or when a mailbox is created on
56
+ * one of its `account` domains. Omitted means open, which is how every
57
+ * namespace behaved before.
58
+ */
59
+ admission?: 'invitation';
27
60
  loginOrigin: string;
28
61
  domains: NamespaceDomain[];
29
62
  mail: {
@@ -31,7 +64,7 @@ export interface NamespaceSpec {
31
64
  productName: string;
32
65
  locale: string;
33
66
  };
34
- services: NamespaceService[];
67
+ services: NamespaceBinding[];
35
68
  }
36
69
  export interface NamespaceManifest {
37
70
  version: 1;
@@ -42,7 +75,7 @@ export interface NamespaceRecord extends Omit<NamespaceSpec, 'status' | 'service
42
75
  status: NamespaceStatus;
43
76
  organizationId: string;
44
77
  projectId: string;
45
- services: (NamespaceService & {
78
+ services: (NamespaceBinding & {
46
79
  appId: string;
47
80
  clientId: string;
48
81
  })[];
@@ -66,6 +99,7 @@ export declare function resolveNamespaceBinding(registry: NamespaceRegistry, inp
66
99
  deploymentId: string;
67
100
  appOrigin: string;
68
101
  }): {
102
+ kind?: "web";
69
103
  id: string;
70
104
  serviceId: string;
71
105
  deploymentId: string;
@@ -100,6 +134,7 @@ export declare function resolveNamespaceContext(selection: NamespaceSelection, o
100
134
  organizationId?: string;
101
135
  }): {
102
136
  context: string;
137
+ kind?: "web";
103
138
  id: string;
104
139
  serviceId: string;
105
140
  deploymentId: string;
@@ -1,3 +1,6 @@
1
+ export function isWebService(binding) {
2
+ return binding.kind === undefined || binding.kind === 'web';
3
+ }
1
4
  function invalid(field) {
2
5
  throw new Error(`Invalid namespace configuration: ${field}`);
3
6
  }
@@ -77,18 +80,39 @@ 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 = ['appOrigin', 'redirectUris', 'postLogoutRedirectUris', 'registration'];
80
90
  function service(value) {
81
91
  const s = object(value, 'service');
92
+ const kind = s.kind === undefined ? 'web' : oneOf(s.kind, ['web', 'native', 'api'], 'kind');
93
+ const identity = {
94
+ id: id(s.id, 'binding id'),
95
+ serviceId: id(s.serviceId, 'serviceId'),
96
+ deploymentId: id(s.deploymentId, 'deploymentId'),
97
+ };
98
+ if (kind !== 'web') {
99
+ // Nothing is redirected to installed software or a resource server; a
100
+ // stray browser field is a mistaken kind, not something to ignore.
101
+ if (WEB_FIELDS.some((field) => s[field] !== undefined))
102
+ invalid(`${kind} service web fields`);
103
+ if (kind === 'native') {
104
+ if (s.credentialRef !== undefined)
105
+ invalid('native service credentialRef');
106
+ return { kind, ...identity };
107
+ }
108
+ return { kind, ...identity, credentialRef: credential(s.credentialRef) };
109
+ }
82
110
  const appOrigin = url(s.appOrigin, 'appOrigin', true, true);
83
- const credentialRef = string(s.credentialRef, 'credentialRef');
84
- if (!/^[A-Z][A-Z0-9_]{2,100}$/.test(credentialRef) || credentialRef.endsWith('_PAT'))
85
- invalid('credentialRef');
111
+ const credentialRef = credential(s.credentialRef);
86
112
  if (s.registration !== undefined && typeof s.registration !== 'boolean')
87
113
  invalid('registration');
88
114
  return {
89
- id: id(s.id, 'binding id'),
90
- serviceId: id(s.serviceId, 'serviceId'),
91
- deploymentId: id(s.deploymentId, 'deploymentId'),
115
+ ...identity,
92
116
  appOrigin,
93
117
  redirectUris: uris(s.redirectUris, appOrigin, 'redirectUris'),
94
118
  postLogoutRedirectUris: uris(s.postLogoutRedirectUris, appOrigin, 'postLogoutRedirectUris'),
@@ -129,11 +153,21 @@ function parse(raw, resolved) {
129
153
  if (!services.length)
130
154
  invalid('namespace needs a service');
131
155
  unique(services.map((s) => `${s.serviceId}:${s.deploymentId}`), 'namespace service/deployment');
156
+ const admission = oneOf(n.admission ?? 'open', ['open', 'invitation'], 'admission');
157
+ if (admission === 'invitation') {
158
+ // The organization's addresses are what an invitation-only namespace
159
+ // is for: mail provisioning invites from them.
160
+ if (!domains.some((d) => d.purpose === 'account'))
161
+ invalid('invitation namespace needs an account domain');
162
+ if (services.some((s) => isWebService(s) && s.registration))
163
+ invalid('invitation namespace cannot open registration');
164
+ }
132
165
  const common = {
133
166
  id: id(n.id, 'namespace id'),
134
167
  displayName: string(n.displayName, 'displayName'),
135
168
  revision: n.revision,
136
169
  status,
170
+ ...(admission === 'invitation' ? { admission } : {}),
137
171
  loginOrigin,
138
172
  domains,
139
173
  services,
@@ -153,7 +187,7 @@ function parse(raw, resolved) {
153
187
  });
154
188
  unique(namespaces.map((n) => n.id), 'namespace id');
155
189
  unique(namespaces.flatMap((n) => n.services.map((s) => s.id)), 'binding id');
156
- unique(namespaces.flatMap((n) => n.services.map((s) => s.credentialRef)), 'credential reference');
190
+ unique(namespaces.flatMap((n) => n.services.flatMap((s) => ('credentialRef' in s ? [s.credentialRef] : []))), 'credential reference');
157
191
  const domains = new Map();
158
192
  for (const n of namespaces)
159
193
  for (const d of n.domains) {
@@ -183,7 +217,9 @@ export function resolveNamespaceBinding(registry, input) {
183
217
  const namespace = validated.namespaces.find((n) => n.id === input.namespaceId);
184
218
  if (!namespace || namespace.status !== 'active')
185
219
  invalid('namespace unavailable');
186
- const binding = namespace.services.find((s) => s.serviceId === input.serviceId && s.deploymentId === input.deploymentId);
220
+ const binding = namespace.services
221
+ .filter(isWebService)
222
+ .find((s) => s.serviceId === input.serviceId && s.deploymentId === input.deploymentId);
187
223
  if (!binding || binding.appOrigin !== url(input.appOrigin, 'appOrigin', true, true))
188
224
  invalid('service binding mismatch');
189
225
  return {
@@ -206,7 +242,7 @@ export function parseWorkspaceBindings(raw, registry) {
206
242
  namespaceId: id(w.namespaceId, 'namespaceId'),
207
243
  };
208
244
  const namespace = validated.namespaces.find((n) => n.id === binding.namespaceId);
209
- if (!namespace?.services.some((s) => s.serviceId === binding.serviceId))
245
+ if (!namespace?.services.some((s) => isWebService(s) && s.serviceId === binding.serviceId))
210
246
  invalid('workspace service/namespace mismatch');
211
247
  return binding;
212
248
  });
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@wtfalch/auth",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Sign in against auth.wtfalch.dev: 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",
7
- "url": "https://github.com/wtfalch/auth",
7
+ "url": "git+https://github.com/wtfalch/auth.git",
8
8
  "directory": "packages/auth"
9
9
  },
10
10
  "type": "module",