@authzflow/server 0.0.0-stage → 0.1.0-alpha.8
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/LICENSE +21 -0
- package/README.md +14 -2
- package/THIRD_PARTY_NOTICES.md +6340 -0
- package/dist/change-feed.d.ts +67 -0
- package/dist/change-feed.js +40 -0
- package/dist/index.d.ts +70 -0
- package/dist/index.js +60 -0
- package/package.json +31 -4
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { CheckResult, CheckResponse } from './index.js';
|
|
2
|
+
export interface StreamPosition {
|
|
3
|
+
epoch: string;
|
|
4
|
+
sequence: string;
|
|
5
|
+
cursor: string;
|
|
6
|
+
}
|
|
7
|
+
export interface ChangeBatch {
|
|
8
|
+
binding: string;
|
|
9
|
+
epoch: string;
|
|
10
|
+
from_sequence: string;
|
|
11
|
+
through_sequence: string;
|
|
12
|
+
next_cursor: string;
|
|
13
|
+
changes: {
|
|
14
|
+
sequence: string;
|
|
15
|
+
target: {
|
|
16
|
+
kind: 'scope' | 'resource';
|
|
17
|
+
id: string;
|
|
18
|
+
};
|
|
19
|
+
action: 'revoke' | 'revalidate' | 'invalidate' | 'purge' | 'grant';
|
|
20
|
+
}[];
|
|
21
|
+
}
|
|
22
|
+
/** Host serializes checks and all relevant permission mutations in the same barrier.
|
|
23
|
+
* Append a conservative invalidation durably BEFORE changing the upstream ACL.
|
|
24
|
+
* Only a successful fresh check can grant access. Unknown/expired cursors reset.
|
|
25
|
+
*/
|
|
26
|
+
export interface ChangeJournal<Target> {
|
|
27
|
+
barrier<T>(run: () => Promise<T>): Promise<T>;
|
|
28
|
+
subscribe(binding: string, target: Target): Promise<StreamPosition>;
|
|
29
|
+
read(binding: string, target: Target, cursor: string, limit: number): Promise<{
|
|
30
|
+
batch: ChangeBatch;
|
|
31
|
+
hasMore: boolean;
|
|
32
|
+
}>;
|
|
33
|
+
}
|
|
34
|
+
export type FeedResult = {
|
|
35
|
+
status: number;
|
|
36
|
+
headers: Readonly<{
|
|
37
|
+
'Cache-Control': 'no-store';
|
|
38
|
+
}>;
|
|
39
|
+
body: unknown;
|
|
40
|
+
};
|
|
41
|
+
export interface ChangeFeedOptions<Context, Target> {
|
|
42
|
+
authenticate(context: Context): Promise<{
|
|
43
|
+
binding: string;
|
|
44
|
+
} | null>;
|
|
45
|
+
check(input: {
|
|
46
|
+
query: Readonly<Record<string, unknown>>;
|
|
47
|
+
context: Context;
|
|
48
|
+
target: Target;
|
|
49
|
+
}): Promise<CheckResult>;
|
|
50
|
+
journal: ChangeJournal<Target>;
|
|
51
|
+
}
|
|
52
|
+
export interface ChangeFeed<Context, Target> {
|
|
53
|
+
snapshot(input: {
|
|
54
|
+
query: Readonly<Record<string, unknown>>;
|
|
55
|
+
context: Context;
|
|
56
|
+
target: Target;
|
|
57
|
+
}): Promise<FeedResult>;
|
|
58
|
+
changes(input: {
|
|
59
|
+
query: Readonly<Record<string, unknown>>;
|
|
60
|
+
context: Context;
|
|
61
|
+
target: Target;
|
|
62
|
+
}): Promise<FeedResult>;
|
|
63
|
+
}
|
|
64
|
+
export type StreamCheck = CheckResponse & {
|
|
65
|
+
stream: StreamPosition;
|
|
66
|
+
};
|
|
67
|
+
export declare function createChangeFeed<Context, Target>(options: ChangeFeedOptions<Context, Target>): ChangeFeed<Context, Target>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export function createChangeFeed(options) {
|
|
2
|
+
const headers = Object.freeze({ 'Cache-Control': 'no-store' });
|
|
3
|
+
const failure = (status, code) => ({ status, headers, body: { error: { code } } });
|
|
4
|
+
return {
|
|
5
|
+
async snapshot(input) {
|
|
6
|
+
try {
|
|
7
|
+
return await options.journal.barrier(async () => {
|
|
8
|
+
const result = await options.check(input);
|
|
9
|
+
if (result.status !== 200)
|
|
10
|
+
return result;
|
|
11
|
+
const stream = await options.journal.subscribe(result.body.binding, input.target);
|
|
12
|
+
return { ...result, headers, body: { ...result.body, stream } };
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return failure(503, 'authz_changes_unavailable');
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
async changes({ query, context, target }) {
|
|
20
|
+
const requestId = query.requestId, cursor = query.cursor;
|
|
21
|
+
if (typeof requestId !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(requestId)
|
|
22
|
+
|| typeof cursor !== 'string' || !cursor || cursor.length > 8192
|
|
23
|
+
|| Object.keys(query).length !== 2)
|
|
24
|
+
return failure(400, 'invalid_authz_changes');
|
|
25
|
+
try {
|
|
26
|
+
const identity = await options.authenticate(context);
|
|
27
|
+
if (!identity)
|
|
28
|
+
return failure(401, 'user_session_required');
|
|
29
|
+
if (typeof identity.binding !== 'string' || !identity.binding || identity.binding.length > 4096)
|
|
30
|
+
throw Error();
|
|
31
|
+
const result = await options.journal.barrier(() => options.journal.read(identity.binding, target, cursor, 100));
|
|
32
|
+
return { status: 200, headers, body: { schemaVersion: 1, requestId, ...result } };
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
return error instanceof Error && error.message === 'cursor_reset_required'
|
|
36
|
+
? failure(409, 'reset_required') : failure(503, 'authz_changes_unavailable');
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export { createChangeFeed, type ChangeFeed, type ChangeFeedOptions, type ChangeJournal, type ChangeBatch, type StreamPosition, type StreamCheck } from './change-feed.js';
|
|
2
|
+
export type ReadPolicy = {
|
|
3
|
+
mode: 'online_only';
|
|
4
|
+
} | {
|
|
5
|
+
mode: 'offline_ttl';
|
|
6
|
+
ttlMinutes: number;
|
|
7
|
+
};
|
|
8
|
+
export type CheckMetadata = {
|
|
9
|
+
scopeId: string;
|
|
10
|
+
resourceId: string;
|
|
11
|
+
contentHash: string;
|
|
12
|
+
};
|
|
13
|
+
export type AuthorizationDecision = {
|
|
14
|
+
allowed: true;
|
|
15
|
+
metadata: CheckMetadata;
|
|
16
|
+
} | {
|
|
17
|
+
allowed: false;
|
|
18
|
+
reason: 'forbidden' | 'not_found';
|
|
19
|
+
};
|
|
20
|
+
export type CheckResponse = CheckMetadata & {
|
|
21
|
+
schemaVersion: 1;
|
|
22
|
+
mode: 'check_only';
|
|
23
|
+
requestId: string;
|
|
24
|
+
binding: string;
|
|
25
|
+
epoch: string;
|
|
26
|
+
leaseMs: number;
|
|
27
|
+
readPolicy: {
|
|
28
|
+
mode: 'online_only' | 'offline_ttl';
|
|
29
|
+
maxOfflineMs: number;
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
export type CheckError = 'invalid_authz_check' | 'user_session_required' | 'access_required' | 'resource_not_found' | 'authz_check_unavailable';
|
|
33
|
+
export type CheckResult = {
|
|
34
|
+
status: 200;
|
|
35
|
+
headers: Readonly<{
|
|
36
|
+
'Cache-Control': 'no-store';
|
|
37
|
+
}>;
|
|
38
|
+
body: CheckResponse;
|
|
39
|
+
} | {
|
|
40
|
+
status: 400 | 401 | 403 | 404 | 503;
|
|
41
|
+
headers: Readonly<{
|
|
42
|
+
'Cache-Control': 'no-store';
|
|
43
|
+
}>;
|
|
44
|
+
body: {
|
|
45
|
+
error: {
|
|
46
|
+
code: CheckError;
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
export type ProviderOptions<Context, Principal, Target> = {
|
|
51
|
+
authenticate(context: Context): Promise<{
|
|
52
|
+
binding: string;
|
|
53
|
+
principal: Principal;
|
|
54
|
+
} | null>;
|
|
55
|
+
authorize(input: {
|
|
56
|
+
context: Context;
|
|
57
|
+
principal: Principal;
|
|
58
|
+
target: Target;
|
|
59
|
+
}): Promise<AuthorizationDecision>;
|
|
60
|
+
readPolicy?: ReadPolicy;
|
|
61
|
+
leaseMs?: number;
|
|
62
|
+
};
|
|
63
|
+
export type CheckProvider<Context, Target> = {
|
|
64
|
+
check(input: {
|
|
65
|
+
query: Readonly<Record<string, unknown>>;
|
|
66
|
+
context: Context;
|
|
67
|
+
target: Target;
|
|
68
|
+
}): Promise<CheckResult>;
|
|
69
|
+
};
|
|
70
|
+
export declare function createCheckProvider<Context, Principal, Target>(options: ProviderOptions<Context, Principal, Target>): CheckProvider<Context, Target>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export { createChangeFeed } from './change-feed.js';
|
|
2
|
+
export function createCheckProvider(options) {
|
|
3
|
+
const policy = options.readPolicy ?? { mode: 'online_only' };
|
|
4
|
+
const maxOfflineMs = policy.mode === 'offline_ttl' ? policy.ttlMinutes * 60_000 : 0;
|
|
5
|
+
const leaseMs = options.leaseMs ?? 5000;
|
|
6
|
+
if (!['online_only', 'offline_ttl'].includes(policy.mode)
|
|
7
|
+
|| (policy.mode === 'offline_ttl' && typeof policy.ttlMinutes !== 'number')
|
|
8
|
+
|| !Number.isSafeInteger(maxOfflineMs) || maxOfflineMs < 0 || maxOfflineMs > 86_400_000
|
|
9
|
+
|| (policy.mode === 'offline_ttl' && maxOfflineMs === 0)
|
|
10
|
+
|| !Number.isSafeInteger(leaseMs) || leaseMs < 1 || leaseMs > 5000
|
|
11
|
+
|| typeof options.authenticate !== 'function' || typeof options.authorize !== 'function') {
|
|
12
|
+
throw new Error('authz_server_config_invalid');
|
|
13
|
+
}
|
|
14
|
+
const readPolicy = Object.freeze({ mode: policy.mode, maxOfflineMs });
|
|
15
|
+
const headers = Object.freeze({ 'Cache-Control': 'no-store' });
|
|
16
|
+
const { authenticate, authorize } = options;
|
|
17
|
+
return {
|
|
18
|
+
async check({ query, context, target }) {
|
|
19
|
+
// Copy the nonce before awaiting host I/O; client input never supplies identity or policy.
|
|
20
|
+
const requestId = query.requestId;
|
|
21
|
+
if (typeof requestId !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(requestId)
|
|
22
|
+
|| Object.keys(query).length !== 1) {
|
|
23
|
+
return { status: 400, headers, body: { error: { code: 'invalid_authz_check' } } };
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const identity = await authenticate(context);
|
|
27
|
+
if (identity === null)
|
|
28
|
+
return { status: 401, headers, body: { error: { code: 'user_session_required' } } };
|
|
29
|
+
if (typeof identity.binding !== 'string' || identity.binding.length === 0 || identity.binding.length > 4096) {
|
|
30
|
+
throw new Error('authz_adapter_invalid');
|
|
31
|
+
}
|
|
32
|
+
const binding = identity.binding;
|
|
33
|
+
const decision = await authorize({ context, principal: identity.principal, target });
|
|
34
|
+
if (decision.allowed === false) {
|
|
35
|
+
if (decision.reason === 'forbidden')
|
|
36
|
+
return { status: 403, headers, body: { error: { code: 'access_required' } } };
|
|
37
|
+
if (decision.reason === 'not_found')
|
|
38
|
+
return { status: 404, headers, body: { error: { code: 'resource_not_found' } } };
|
|
39
|
+
throw new Error('authz_adapter_invalid');
|
|
40
|
+
}
|
|
41
|
+
if (decision.allowed !== true)
|
|
42
|
+
throw new Error('authz_adapter_invalid');
|
|
43
|
+
const { scopeId, resourceId, contentHash } = decision.metadata;
|
|
44
|
+
if (typeof scopeId !== 'string' || scopeId.length === 0 || scopeId.length > 4096
|
|
45
|
+
|| typeof resourceId !== 'string' || resourceId.length === 0 || resourceId.length > 4096
|
|
46
|
+
|| typeof contentHash !== 'string' || !/^[a-f0-9]{64}$/.test(contentHash)) {
|
|
47
|
+
throw new Error('authz_adapter_invalid');
|
|
48
|
+
}
|
|
49
|
+
return { status: 200, headers, body: {
|
|
50
|
+
schemaVersion: 1, mode: 'check_only', requestId, binding,
|
|
51
|
+
epoch: globalThis.crypto.randomUUID(), scopeId, resourceId, contentHash, leaseMs, readPolicy,
|
|
52
|
+
} };
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Host exceptions can contain credentials, SQL or private URLs. Never return or log them.
|
|
56
|
+
return { status: 503, headers, body: { error: { code: 'authz_check_unavailable' } } };
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@authzflow/server",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0-alpha.8",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"author": "Accelflow Inc.",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"import": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"LICENSE",
|
|
16
|
+
"README.md",
|
|
17
|
+
"THIRD_PARTY_NOTICES.md"
|
|
18
|
+
],
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "https://github.com/Accelflow/AuthzFlow.git"
|
|
22
|
+
},
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"description": "Framework-neutral authorization complete-check provider",
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=22"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public",
|
|
30
|
+
"provenance": true,
|
|
31
|
+
"registry": "https://registry.npmjs.org/"
|
|
32
|
+
}
|
|
33
|
+
}
|