@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 +3 -0
- package/dist/broker.js +2 -0
- package/dist/namespace-server.js +7 -3
- package/dist/namespace-session.js +27 -2
- package/dist/namespaces.d.ts +37 -2
- package/dist/namespaces.js +45 -9
- package/package.json +2 -2
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));
|
package/dist/namespace-server.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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.
|
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;
|
|
@@ -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:
|
|
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: (
|
|
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;
|
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,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 =
|
|
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
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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",
|