@hivemagnus/hive-common 0.0.0-stage → 0.1.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +11 -0
  4. package/README.md +382 -2
  5. package/dist/context/index.d.ts +86 -0
  6. package/dist/context/index.js +189 -0
  7. package/dist/contracts/index.d.ts +117 -0
  8. package/dist/contracts/index.js +228 -0
  9. package/dist/errors/index.d.ts +91 -0
  10. package/dist/errors/index.js +309 -0
  11. package/dist/events/index.d.ts +107 -0
  12. package/dist/events/index.js +232 -0
  13. package/dist/health/index.d.ts +85 -0
  14. package/dist/health/index.js +113 -0
  15. package/dist/hono/index.d.ts +64 -0
  16. package/dist/hono/index.js +105 -0
  17. package/dist/log/index.d.ts +39 -0
  18. package/dist/log/index.js +80 -0
  19. package/dist/log/redact.d.ts +23 -0
  20. package/dist/log/redact.js +111 -0
  21. package/dist/node-http/index.d.ts +43 -0
  22. package/dist/node-http/index.js +88 -0
  23. package/dist/oathweave/claims.d.ts +63 -0
  24. package/dist/oathweave/claims.js +91 -0
  25. package/dist/oathweave/conformance.d.ts +88 -0
  26. package/dist/oathweave/conformance.js +124 -0
  27. package/dist/oathweave/endpoint.d.ts +15 -0
  28. package/dist/oathweave/endpoint.js +35 -0
  29. package/dist/oathweave/errors.d.ts +19 -0
  30. package/dist/oathweave/errors.js +70 -0
  31. package/dist/oathweave/index.d.ts +10 -0
  32. package/dist/oathweave/index.js +10 -0
  33. package/dist/oathweave/links.d.ts +61 -0
  34. package/dist/oathweave/links.js +131 -0
  35. package/dist/oathweave/stream.d.ts +26 -0
  36. package/dist/oathweave/stream.js +46 -0
  37. package/dist/oathweave/verifier.d.ts +69 -0
  38. package/dist/oathweave/verifier.js +226 -0
  39. package/dist/registry/index.d.ts +138 -0
  40. package/dist/registry/index.js +175 -0
  41. package/package.json +126 -4
  42. package/test-vectors/README.md +27 -0
  43. package/test-vectors/oathweave/jwks.json +12 -0
  44. package/test-vectors/oathweave/vectors.json +174 -0
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Oathweave verifier conformance. The package ships `test-vectors/oathweave/vectors.json`:
3
+ * a test JWKS and pre-signed tokens with expected outcomes, evaluated at a fixed clock so
4
+ * the vectors never rot. Each product runs them against its own verifier:
5
+ *
6
+ * const report = await runOathweaveConformance((token, ctx) => myVerify(token, ctx));
7
+ */
8
+ import { readFile } from 'node:fs/promises';
9
+ import { createOathweaveVerifier } from './verifier.js';
10
+ /** Load the shipped vectors. */
11
+ export async function loadOathweaveVectors() {
12
+ const file = new URL('../../test-vectors/oathweave/vectors.json', import.meta.url);
13
+ return JSON.parse(await readFile(file, 'utf8'));
14
+ }
15
+ function decodePayload(token) {
16
+ const part = token.split('.')[1] ?? '';
17
+ return JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
18
+ }
19
+ function vectorFetch(vectors) {
20
+ return async (input, init) => {
21
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
22
+ if (url === vectors.jwksUrl)
23
+ return Response.json(vectors.jwks);
24
+ if (url === vectors.userinfoUrl) {
25
+ const header = new Headers(init?.headers).get('authorization') ?? '';
26
+ const token = /^Bearer (.+)$/.exec(header)?.[1];
27
+ if (!token)
28
+ return new Response('', { status: 401 });
29
+ const payload = decodePayload(token);
30
+ return Response.json({ sub: payload['sub'], name: 'Conformance User' });
31
+ }
32
+ return new Response('', { status: 404 });
33
+ };
34
+ }
35
+ function sameSet(a, b) {
36
+ return a.length === b.length && [...a].sort().join('\n') === [...b].sort().join('\n');
37
+ }
38
+ function checkAccepted(expect, value) {
39
+ if (!value || typeof value !== 'object')
40
+ return undefined;
41
+ const result = value;
42
+ // Only fields the verifier exposes are compared, so differently-shaped results still pass.
43
+ if (typeof result['kind'] === 'string' && result['kind'] !== expect.kind)
44
+ return `kind ${String(result['kind'])}, expected ${expect.kind}`;
45
+ if (typeof result['subject'] === 'string' && result['subject'] !== expect.subject)
46
+ return 'subject differs';
47
+ for (const field of ['roles', 'permissions', 'scopes']) {
48
+ const actual = result[field];
49
+ if (Array.isArray(actual) && !sameSet(actual, expect[field]))
50
+ return `${field} differ`;
51
+ }
52
+ return undefined;
53
+ }
54
+ function checkRejected(expect, error, strict) {
55
+ const record = error && typeof error === 'object' ? error : {};
56
+ const status = record['status'] ?? record['statusCode'];
57
+ if (typeof status === 'number' && status !== expect.status)
58
+ return `status ${status}, expected ${expect.status}`;
59
+ if (strict && record['reason'] !== expect.reason)
60
+ return `reason ${String(record['reason'])}, expected ${expect.reason}`;
61
+ return undefined;
62
+ }
63
+ /**
64
+ * Run every vector through `verify` at the vectors' fixed clock. A vector expecting acceptance
65
+ * passes when `verify` resolves (and any `kind`, `subject`, `roles`, `permissions` or `scopes`
66
+ * on the result match); one expecting refusal passes when it rejects (and any numeric
67
+ * `status` on the error matches, plus `reason` with `strictReasons`).
68
+ */
69
+ export async function runOathweaveConformance(verify, options = {}) {
70
+ const vectors = options.vectors ?? await loadOathweaveVectors();
71
+ const fixed = new Date(vectors.now);
72
+ const fetcher = vectorFetch(vectors);
73
+ const results = [];
74
+ for (const vector of vectors.vectors) {
75
+ if (options.only && !options.only.includes(vector.name))
76
+ continue;
77
+ const context = {
78
+ issuer: vectors.issuer,
79
+ audience: vectors.audience,
80
+ jwks: vectors.jwks,
81
+ jwksUrl: vectors.jwksUrl,
82
+ userinfoUrl: vectors.userinfoUrl,
83
+ now: new Date(fixed),
84
+ nowMs: fixed.getTime(),
85
+ principal: vector.principal,
86
+ vector,
87
+ fetch: fetcher,
88
+ };
89
+ let actual;
90
+ let problem;
91
+ try {
92
+ const value = await verify(vector.token, context);
93
+ actual = 'accept';
94
+ if (vector.expect.outcome === 'accept')
95
+ problem = checkAccepted(vector.expect, value);
96
+ else
97
+ problem = `accepted a token that must be refused (${vector.expect.reason})`;
98
+ }
99
+ catch (error) {
100
+ actual = 'reject';
101
+ if (vector.expect.outcome === 'reject')
102
+ problem = checkRejected(vector.expect, error, options.strictReasons ?? false);
103
+ else
104
+ problem = `refused a valid token: ${error instanceof Error ? error.message : String(error)}`;
105
+ }
106
+ results.push({ name: vector.name, passed: problem === undefined, expected: vector.expect.outcome, actual, ...(problem ? { problem } : {}) });
107
+ }
108
+ const failed = results.filter(result => !result.passed);
109
+ const report = { passed: results.length - failed.length, failed: failed.length, results };
110
+ if (failed.length && (options.throwOnFailure ?? true)) {
111
+ throw new Error(`Oathweave conformance failed for ${failed.length} of ${results.length} vectors:\n${failed.map(result => `- ${result.name}: ${result.problem}`).join('\n')}`);
112
+ }
113
+ return report;
114
+ }
115
+ /** A `ConformanceVerify` for this package's own verifier, for products that adopt it as is. */
116
+ export function hiveCommonConformanceVerify() {
117
+ return (token, context) => createOathweaveVerifier({
118
+ issuer: context.issuer,
119
+ audience: context.audience,
120
+ keys: context.jwks,
121
+ now: () => context.nowMs,
122
+ accept: 'any',
123
+ }).verify(token);
124
+ }
@@ -0,0 +1,15 @@
1
+ /** Oathweave issuer and endpoint URL validation. */
2
+ export interface EndpointOptions {
3
+ /**
4
+ * Allow `http:` for loopback hosts (`localhost`, `127.0.0.0/8`, `::1`). For tests and local
5
+ * development only; never enable it in production configuration.
6
+ */
7
+ readonly allowInsecureLoopback?: boolean;
8
+ }
9
+ export declare function isLoopbackHost(hostname: string): boolean;
10
+ /** Why a URL is not an acceptable Oathweave endpoint, or undefined when it is. */
11
+ export declare function endpointProblem(value: string, options?: EndpointOptions): string | undefined;
12
+ /** Oathweave endpoints carry bearer tokens, so only verified HTTPS is acceptable. */
13
+ export declare function isOathweaveEndpoint(value: string, options?: EndpointOptions): boolean;
14
+ /** Throw a configuration error naming the setting when `value` is not an acceptable endpoint. */
15
+ export declare function assertOathweaveEndpoint(value: string, name: string, options?: EndpointOptions): void;
@@ -0,0 +1,35 @@
1
+ /** Oathweave issuer and endpoint URL validation. */
2
+ const LOOPBACK_V4 = /^127(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3}$/;
3
+ export function isLoopbackHost(hostname) {
4
+ const host = hostname.toLowerCase().replace(/^\[|\]$/g, '');
5
+ return host === 'localhost' || host.endsWith('.localhost') || host === '::1' || LOOPBACK_V4.test(host);
6
+ }
7
+ /** Why a URL is not an acceptable Oathweave endpoint, or undefined when it is. */
8
+ export function endpointProblem(value, options = {}) {
9
+ let url;
10
+ try {
11
+ url = new URL(value);
12
+ }
13
+ catch {
14
+ return 'is not a valid URL';
15
+ }
16
+ if (url.username || url.password)
17
+ return 'must not contain credentials';
18
+ if (url.hash)
19
+ return 'must not contain a fragment';
20
+ if (url.protocol === 'https:')
21
+ return undefined;
22
+ if (url.protocol === 'http:' && options.allowInsecureLoopback && isLoopbackHost(url.hostname))
23
+ return undefined;
24
+ return url.protocol === 'http:' ? 'must use https (plain http is allowed only for loopback in tests)' : 'must use https';
25
+ }
26
+ /** Oathweave endpoints carry bearer tokens, so only verified HTTPS is acceptable. */
27
+ export function isOathweaveEndpoint(value, options = {}) {
28
+ return endpointProblem(value, options) === undefined;
29
+ }
30
+ /** Throw a configuration error naming the setting when `value` is not an acceptable endpoint. */
31
+ export function assertOathweaveEndpoint(value, name, options = {}) {
32
+ const problem = endpointProblem(value, options);
33
+ if (problem)
34
+ throw new TypeError(`Oathweave ${name} ${problem}`);
35
+ }
@@ -0,0 +1,19 @@
1
+ import { HiveError } from '../errors/index.js';
2
+ /** Why a token or caller was refused. Stable: products and the conformance vectors rely on it. */
3
+ export type OathweaveFailureReason = 'missing_token' | 'malformed_token' | 'algorithm_not_allowed' | 'unknown_key' | 'invalid_signature' | 'expired' | 'not_yet_valid' | 'invalid_issuer' | 'invalid_audience' | 'invalid_lifetime' | 'invalid_claims' | 'wrong_principal_kind' | 'session_inactive' | 'subject_mismatch' | 'not_linked' | 'insufficient_permission' | 'jwks_unavailable' | 'identity_unavailable';
4
+ /** The HTTP status for a failure reason: 401, 403 or 503. */
5
+ export declare function statusForReason(reason: OathweaveFailureReason): 401 | 403 | 503;
6
+ /**
7
+ * A refused Oathweave credential. It is a HiveError, so error handlers send it as the suite
8
+ * envelope: `{ error: { code: 'invalid_token', message, details: { reason } } }` with 401, 403
9
+ * or 503. Messages are fixed per reason and never include the token or provider responses.
10
+ */
11
+ export declare class OathweaveError extends HiveError {
12
+ readonly name = "OathweaveError";
13
+ readonly reason: OathweaveFailureReason;
14
+ constructor(reason: OathweaveFailureReason, options?: {
15
+ readonly cause?: unknown;
16
+ readonly details?: Record<string, unknown>;
17
+ });
18
+ }
19
+ export declare function isOathweaveError(value: unknown): value is OathweaveError;
@@ -0,0 +1,70 @@
1
+ import { HiveError } from '../errors/index.js';
2
+ const MESSAGES = {
3
+ missing_token: 'A bearer token is required',
4
+ malformed_token: 'The bearer token is malformed',
5
+ algorithm_not_allowed: 'The token signing algorithm is not allowed',
6
+ unknown_key: 'The token was signed by an unknown key',
7
+ invalid_signature: 'The token signature is not valid',
8
+ expired: 'The token has expired',
9
+ not_yet_valid: 'The token is not valid yet',
10
+ invalid_issuer: 'The token issuer is not accepted',
11
+ invalid_audience: 'The token is not for this service',
12
+ invalid_lifetime: 'The token lifetime is not acceptable',
13
+ invalid_claims: 'The token claims are not valid',
14
+ wrong_principal_kind: 'This kind of caller is not accepted here',
15
+ session_inactive: 'The identity session is no longer active',
16
+ subject_mismatch: 'The identity session does not match the token',
17
+ not_linked: 'This identity has no access to this service',
18
+ insufficient_permission: 'The caller lacks a required permission',
19
+ jwks_unavailable: 'The identity provider keys are unavailable',
20
+ identity_unavailable: 'The identity provider is unavailable',
21
+ };
22
+ /** The HTTP status for a failure reason: 401, 403 or 503. */
23
+ export function statusForReason(reason) {
24
+ if (reason === 'not_linked' || reason === 'insufficient_permission')
25
+ return 403;
26
+ if (reason === 'jwks_unavailable' || reason === 'identity_unavailable')
27
+ return 503;
28
+ return 401;
29
+ }
30
+ function codeForReason(reason) {
31
+ const status = statusForReason(reason);
32
+ if (status === 403)
33
+ return reason === 'insufficient_permission' ? 'insufficient_permission' : 'forbidden';
34
+ if (status === 503)
35
+ return 'auth_unavailable';
36
+ return reason === 'missing_token' ? 'unauthorized' : 'invalid_token';
37
+ }
38
+ function challenge(reason) {
39
+ const status = statusForReason(reason);
40
+ if (status === 401)
41
+ return { 'www-authenticate': reason === 'missing_token' ? 'Bearer' : 'Bearer error="invalid_token"' };
42
+ if (reason === 'insufficient_permission')
43
+ return { 'www-authenticate': 'Bearer error="insufficient_scope"' };
44
+ if (status === 503)
45
+ return { 'retry-after': '1' };
46
+ return undefined;
47
+ }
48
+ /**
49
+ * A refused Oathweave credential. It is a HiveError, so error handlers send it as the suite
50
+ * envelope: `{ error: { code: 'invalid_token', message, details: { reason } } }` with 401, 403
51
+ * or 503. Messages are fixed per reason and never include the token or provider responses.
52
+ */
53
+ export class OathweaveError extends HiveError {
54
+ name = 'OathweaveError';
55
+ reason;
56
+ constructor(reason, options = {}) {
57
+ const headers = challenge(reason);
58
+ super(codeForReason(reason), MESSAGES[reason], {
59
+ status: statusForReason(reason),
60
+ details: { reason, ...options.details },
61
+ ...(headers ? { headers } : {}),
62
+ ...(options.cause !== undefined ? { cause: options.cause } : {}),
63
+ });
64
+ this.reason = reason;
65
+ }
66
+ }
67
+ export function isOathweaveError(value) {
68
+ return value instanceof OathweaveError
69
+ || (HiveError.is(value) && typeof value.reason === 'string' && value.name === 'OathweaveError');
70
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * `@hivemagnus/hive-common/oathweave`: Oathweave access-token verification for suite services.
3
+ * Framework-free; see `/hono` and `/node-http` for middleware.
4
+ */
5
+ export { createOathweaveVerifier, parseBearerToken, OATHWEAVE_DEFAULT_CLOCK_TOLERANCE_SEC, OATHWEAVE_FAILED_CHECK_TTL_MS, OATHWEAVE_MAX_LIFETIME_SEC, OATHWEAVE_MAX_TOKEN_LENGTH, OATHWEAVE_REQUEST_TIMEOUT_MS, OATHWEAVE_SESSION_CACHE_MAX, OATHWEAVE_SESSION_CACHE_TTL_MS, OATHWEAVE_STREAM_RECHECK_MS, type OathweaveVerifier, type OathweaveVerifierOptions, type VerifyOptions, } from './verifier.js';
6
+ export { clientIdOf, extractRoles, extractScopes, principalFromClaims, principalKindOf, type AcceptPrincipal, type ClaimPolicy, type OathweavePrincipal, type OathweaveServicePrincipal, type OathweaveUserPrincipal, type PrincipalKind, } from './claims.js';
7
+ export { OathweaveError, isOathweaveError, statusForReason, type OathweaveFailureReason } from './errors.js';
8
+ export { assertOathweaveEndpoint, endpointProblem, isLoopbackHost, isOathweaveEndpoint, type EndpointOptions } from './endpoint.js';
9
+ export { effectivePermissions, hasAnyRole, hasPermissions, hasScopes, IdentityLinkError, parseIdentityLinks, permissionMatches, requireIdentityLink, requirePermissions, uniqueIdentityLink, type OathweaveIdentityLink, type ParseIdentityLinksOptions, type PermissionOptions, } from './links.js';
10
+ export { watchStreamAuthorization, type StreamWatch, type StreamWatchOptions } from './stream.js';
@@ -0,0 +1,10 @@
1
+ /**
2
+ * `@hivemagnus/hive-common/oathweave`: Oathweave access-token verification for suite services.
3
+ * Framework-free; see `/hono` and `/node-http` for middleware.
4
+ */
5
+ export { createOathweaveVerifier, parseBearerToken, OATHWEAVE_DEFAULT_CLOCK_TOLERANCE_SEC, OATHWEAVE_FAILED_CHECK_TTL_MS, OATHWEAVE_MAX_LIFETIME_SEC, OATHWEAVE_MAX_TOKEN_LENGTH, OATHWEAVE_REQUEST_TIMEOUT_MS, OATHWEAVE_SESSION_CACHE_MAX, OATHWEAVE_SESSION_CACHE_TTL_MS, OATHWEAVE_STREAM_RECHECK_MS, } from './verifier.js';
6
+ export { clientIdOf, extractRoles, extractScopes, principalFromClaims, principalKindOf, } from './claims.js';
7
+ export { OathweaveError, isOathweaveError, statusForReason } from './errors.js';
8
+ export { assertOathweaveEndpoint, endpointProblem, isLoopbackHost, isOathweaveEndpoint } from './endpoint.js';
9
+ export { effectivePermissions, hasAnyRole, hasPermissions, hasScopes, IdentityLinkError, parseIdentityLinks, permissionMatches, requireIdentityLink, requirePermissions, uniqueIdentityLink, } from './links.js';
10
+ export { watchStreamAuthorization } from './stream.js';
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Identity links and authorization helpers.
3
+ *
4
+ * A link maps one verified Oathweave identity (issuer + subject) to a local account. Products
5
+ * refuse identities without exactly one link, so a valid token alone never grants access.
6
+ */
7
+ import type { OathweavePrincipal } from './claims.js';
8
+ export interface OathweaveIdentityLink {
9
+ readonly issuer: string;
10
+ readonly subject: string;
11
+ /** The local account this identity acts as. Never `system` or a `service:` actor. */
12
+ readonly localUserId: string;
13
+ /** A role ceiling, when the product uses roles. */
14
+ readonly role?: string;
15
+ readonly displayName?: string;
16
+ /** A permission ceiling: the token's permissions grant only within it. Absent means no ceiling. */
17
+ readonly permissions?: readonly string[];
18
+ }
19
+ export declare class IdentityLinkError extends Error {
20
+ readonly name = "IdentityLinkError";
21
+ }
22
+ export interface ParseIdentityLinksOptions {
23
+ /** Allowed `role` values. When set, every link must carry one of them. */
24
+ readonly roles?: readonly string[];
25
+ /** Allowed `permissions` entries. When set, every listed permission must be one of them. */
26
+ readonly permissions?: readonly string[];
27
+ /**
28
+ * `reject` (default) fails on two links for the same identity. `ignore` keeps them, and
29
+ * `uniqueIdentityLink()` then grants nothing for that identity.
30
+ */
31
+ readonly duplicates?: 'reject' | 'ignore';
32
+ readonly maxLinks?: number;
33
+ }
34
+ /**
35
+ * Parse a JSON array of links, for example from `<PRODUCT>_OATHWEAVE_IDENTITIES_JSON`.
36
+ * Throws an `IdentityLinkError` naming the first problem; the raw value is never echoed.
37
+ */
38
+ export declare function parseIdentityLinks(raw: string | unknown, options?: ParseIdentityLinksOptions): OathweaveIdentityLink[];
39
+ /** The one link for a verified identity. A missing or duplicated link yields nothing. */
40
+ export declare function uniqueIdentityLink<L extends OathweaveIdentityLink>(links: readonly L[], issuer: string, subject: string): L | undefined;
41
+ /** The link for a principal, or a 403 `not_linked` OathweaveError. */
42
+ export declare function requireIdentityLink<L extends OathweaveIdentityLink>(links: readonly L[], principal: Pick<OathweavePrincipal, 'issuer' | 'subject'>): L;
43
+ /**
44
+ * Does one granted permission cover a required one? Exact match, `*`, or a `*` segment
45
+ * (split on `:`) covering the rest: `iam:*` covers `iam:users:read`.
46
+ */
47
+ export declare function permissionMatches(granted: string, required: string): boolean;
48
+ export interface PermissionOptions {
49
+ /** Honour `*` wildcards in granted permissions. Defaults to true, as the Oathweave SDK does. */
50
+ readonly wildcards?: boolean;
51
+ }
52
+ /** True when `granted` covers every one of `required`. */
53
+ export declare function hasPermissions(granted: readonly string[], required: readonly string[], options?: PermissionOptions): boolean;
54
+ /** Throw a 403 `insufficient_permission` OathweaveError unless the principal has every permission. */
55
+ export declare function requirePermissions(principal: Pick<OathweavePrincipal, 'permissions'>, required: readonly string[], options?: PermissionOptions): void;
56
+ /** True when the principal has at least one of `roles`. */
57
+ export declare function hasAnyRole(principal: Pick<OathweavePrincipal, 'roles'>, roles: readonly string[]): boolean;
58
+ /** True when the principal has every one of `scopes`. */
59
+ export declare function hasScopes(principal: Pick<OathweavePrincipal, 'scopes'>, scopes: readonly string[]): boolean;
60
+ /** Token permissions narrowed by a link's ceiling (and optionally a product's known set). */
61
+ export declare function effectivePermissions(link: Pick<OathweaveIdentityLink, 'permissions'>, tokenPermissions: readonly string[], known?: readonly string[]): string[];
@@ -0,0 +1,131 @@
1
+ import { OathweaveError } from './errors.js';
2
+ export class IdentityLinkError extends Error {
3
+ name = 'IdentityLinkError';
4
+ }
5
+ const LINK_FIELDS = new Set(['issuer', 'subject', 'localUserId', 'role', 'displayName', 'permissions']);
6
+ function isHttpsUrl(value) {
7
+ if (typeof value !== 'string')
8
+ return false;
9
+ try {
10
+ return new URL(value).protocol === 'https:';
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ }
16
+ /**
17
+ * Parse a JSON array of links, for example from `<PRODUCT>_OATHWEAVE_IDENTITIES_JSON`.
18
+ * Throws an `IdentityLinkError` naming the first problem; the raw value is never echoed.
19
+ */
20
+ export function parseIdentityLinks(raw, options = {}) {
21
+ let json = raw;
22
+ if (typeof raw === 'string') {
23
+ try {
24
+ json = JSON.parse(raw);
25
+ }
26
+ catch {
27
+ throw new IdentityLinkError('identity links must be valid JSON');
28
+ }
29
+ }
30
+ if (!Array.isArray(json))
31
+ throw new IdentityLinkError('identity links must be a JSON array');
32
+ if (json.length > (options.maxLinks ?? 10_000))
33
+ throw new IdentityLinkError('too many identity links');
34
+ const seen = new Set();
35
+ return json.map((item, index) => {
36
+ const where = `identity link ${index}`;
37
+ if (!item || typeof item !== 'object' || Array.isArray(item))
38
+ throw new IdentityLinkError(`${where} must be an object`);
39
+ const link = item;
40
+ for (const key of Object.keys(link))
41
+ if (!LINK_FIELDS.has(key))
42
+ throw new IdentityLinkError(`${where} has unknown field ${key.slice(0, 40)}`);
43
+ const { issuer, subject, localUserId, role, displayName, permissions } = link;
44
+ if (!isHttpsUrl(issuer))
45
+ throw new IdentityLinkError(`${where}: issuer must be an https URL`);
46
+ if (typeof subject !== 'string' || !subject || subject.length > 256)
47
+ throw new IdentityLinkError(`${where}: subject must be 1-256 characters`);
48
+ if (typeof localUserId !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._@:-]{0,255}$/.test(localUserId)) {
49
+ throw new IdentityLinkError(`${where}: localUserId is not valid`);
50
+ }
51
+ // These identifiers name server-owned actors; a link must never impersonate one.
52
+ if (localUserId === 'system' || localUserId.startsWith('service:')) {
53
+ throw new IdentityLinkError(`${where}: localUserId must not name the system or a service actor`);
54
+ }
55
+ if (options.roles ? typeof role !== 'string' || !options.roles.includes(role) : role !== undefined && (typeof role !== 'string' || !role)) {
56
+ throw new IdentityLinkError(`${where}: role is not allowed`);
57
+ }
58
+ if (displayName !== undefined && (typeof displayName !== 'string' || !displayName.trim() || displayName.length > 120)) {
59
+ throw new IdentityLinkError(`${where}: displayName must be 1-120 characters`);
60
+ }
61
+ if (permissions !== undefined && (!Array.isArray(permissions) || !permissions.every(value => typeof value === 'string'
62
+ && (!options.permissions || options.permissions.includes(value))))) {
63
+ throw new IdentityLinkError(`${where}: permissions are not valid`);
64
+ }
65
+ const key = JSON.stringify([issuer, subject]);
66
+ if (seen.has(key) && (options.duplicates ?? 'reject') === 'reject')
67
+ throw new IdentityLinkError(`${where} duplicates an earlier link`);
68
+ seen.add(key);
69
+ return {
70
+ issuer,
71
+ subject,
72
+ localUserId,
73
+ ...(typeof role === 'string' ? { role } : {}),
74
+ ...(typeof displayName === 'string' ? { displayName: displayName.trim() } : {}),
75
+ ...(Array.isArray(permissions) ? { permissions: [...permissions] } : {}),
76
+ };
77
+ });
78
+ }
79
+ /** The one link for a verified identity. A missing or duplicated link yields nothing. */
80
+ export function uniqueIdentityLink(links, issuer, subject) {
81
+ const matches = links.filter(link => link.issuer === issuer && link.subject === subject);
82
+ return matches.length === 1 ? matches[0] : undefined;
83
+ }
84
+ /** The link for a principal, or a 403 `not_linked` OathweaveError. */
85
+ export function requireIdentityLink(links, principal) {
86
+ const link = uniqueIdentityLink(links, principal.issuer, principal.subject);
87
+ if (!link)
88
+ throw new OathweaveError('not_linked');
89
+ return link;
90
+ }
91
+ /**
92
+ * Does one granted permission cover a required one? Exact match, `*`, or a `*` segment
93
+ * (split on `:`) covering the rest: `iam:*` covers `iam:users:read`.
94
+ */
95
+ export function permissionMatches(granted, required) {
96
+ if (granted === '*' || granted === required)
97
+ return true;
98
+ const grantedParts = granted.split(':');
99
+ const requiredParts = required.split(':');
100
+ for (let index = 0; index < grantedParts.length; index++) {
101
+ if (grantedParts[index] === '*')
102
+ return index < requiredParts.length;
103
+ if (grantedParts[index] !== requiredParts[index])
104
+ return false;
105
+ }
106
+ return grantedParts.length === requiredParts.length;
107
+ }
108
+ /** True when `granted` covers every one of `required`. */
109
+ export function hasPermissions(granted, required, options = {}) {
110
+ const wildcards = options.wildcards ?? true;
111
+ return required.every(need => granted.some(have => (wildcards ? permissionMatches(have, need) : have === need)));
112
+ }
113
+ /** Throw a 403 `insufficient_permission` OathweaveError unless the principal has every permission. */
114
+ export function requirePermissions(principal, required, options = {}) {
115
+ if (!hasPermissions(principal.permissions, required, options)) {
116
+ throw new OathweaveError('insufficient_permission', { details: { required: [...required] } });
117
+ }
118
+ }
119
+ /** True when the principal has at least one of `roles`. */
120
+ export function hasAnyRole(principal, roles) {
121
+ return roles.some(role => principal.roles.includes(role));
122
+ }
123
+ /** True when the principal has every one of `scopes`. */
124
+ export function hasScopes(principal, scopes) {
125
+ return scopes.every(scope => principal.scopes.includes(scope));
126
+ }
127
+ /** Token permissions narrowed by a link's ceiling (and optionally a product's known set). */
128
+ export function effectivePermissions(link, tokenPermissions, known) {
129
+ const ceiling = link.permissions ? new Set(link.permissions) : undefined;
130
+ return [...new Set(tokenPermissions)].filter(permission => (!ceiling || ceiling.has(permission)) && (!known || known.includes(permission)));
131
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Re-checking authorization on long-lived connections (SSE, WebSocket, long poll).
3
+ *
4
+ * A stream verified once at open would outlive a sign-out. `watchStreamAuthorization()`
5
+ * re-verifies every `OATHWEAVE_STREAM_RECHECK_MS` (the verifier's 10 s session cache keeps
6
+ * that cheap) and ends the stream at the token's expiry, so sign-out takes effect within
7
+ * about 15 seconds on open streams as well as on ordinary requests.
8
+ */
9
+ import { OathweaveError } from './errors.js';
10
+ export interface StreamWatchOptions {
11
+ /** Re-verify the caller. Usually `() => verifier.verify(token)`. Rejecting ends the stream. */
12
+ readonly recheck: () => Promise<{
13
+ readonly expiresAt: number;
14
+ }>;
15
+ /** Token expiry in epoch seconds. The stream ends then even if rechecks pass. */
16
+ readonly expiresAt: number;
17
+ /** Called once when the stream must close. */
18
+ readonly onEnd: (error: OathweaveError) => void;
19
+ readonly intervalMs?: number;
20
+ readonly now?: () => number;
21
+ }
22
+ export interface StreamWatch {
23
+ stop(): void;
24
+ readonly stopped: boolean;
25
+ }
26
+ export declare function watchStreamAuthorization(options: StreamWatchOptions): StreamWatch;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Re-checking authorization on long-lived connections (SSE, WebSocket, long poll).
3
+ *
4
+ * A stream verified once at open would outlive a sign-out. `watchStreamAuthorization()`
5
+ * re-verifies every `OATHWEAVE_STREAM_RECHECK_MS` (the verifier's 10 s session cache keeps
6
+ * that cheap) and ends the stream at the token's expiry, so sign-out takes effect within
7
+ * about 15 seconds on open streams as well as on ordinary requests.
8
+ */
9
+ import { OathweaveError } from './errors.js';
10
+ import { OATHWEAVE_STREAM_RECHECK_MS } from './verifier.js';
11
+ export function watchStreamAuthorization(options) {
12
+ const now = options.now ?? Date.now;
13
+ const intervalMs = options.intervalMs ?? OATHWEAVE_STREAM_RECHECK_MS;
14
+ let stopped = false;
15
+ let inFlight = false;
16
+ const end = (error) => {
17
+ if (stopped)
18
+ return;
19
+ stop();
20
+ options.onEnd(error);
21
+ };
22
+ const expiryDelay = Math.max(0, options.expiresAt * 1000 - now());
23
+ // setTimeout cannot hold more than ~24.8 days; tokens never live that long.
24
+ const expiryTimer = setTimeout(() => end(new OathweaveError('expired')), Math.min(expiryDelay, 2_147_483_647));
25
+ const interval = setInterval(() => {
26
+ if (inFlight || stopped)
27
+ return;
28
+ inFlight = true;
29
+ options.recheck().then(() => { inFlight = false; }, (error) => {
30
+ inFlight = false;
31
+ end(error instanceof OathweaveError ? error : new OathweaveError('session_inactive', { cause: error }));
32
+ });
33
+ }, intervalMs);
34
+ // Never keep a process alive just to watch a stream.
35
+ expiryTimer.unref?.();
36
+ interval.unref?.();
37
+ function stop() {
38
+ stopped = true;
39
+ clearTimeout(expiryTimer);
40
+ clearInterval(interval);
41
+ }
42
+ return {
43
+ stop,
44
+ get stopped() { return stopped; },
45
+ };
46
+ }
@@ -0,0 +1,69 @@
1
+ import { type JSONWebKeySet, type JWTVerifyGetKey } from 'jose';
2
+ import { type AcceptPrincipal, type OathweavePrincipal, type OathweaveServicePrincipal, type OathweaveUserPrincipal } from './claims.js';
3
+ import { type EndpointOptions } from './endpoint.js';
4
+ /** How long a userinfo answer is reused, measured from when its request started. */
5
+ export declare const OATHWEAVE_SESSION_CACHE_TTL_MS = 10000;
6
+ /** How often a long-lived connection should re-check its token. */
7
+ export declare const OATHWEAVE_STREAM_RECHECK_MS = 5000;
8
+ /** Failed session checks are remembered this long, so one failing request does not verify twice. */
9
+ export declare const OATHWEAVE_FAILED_CHECK_TTL_MS = 2000;
10
+ export declare const OATHWEAVE_SESSION_CACHE_MAX = 5000;
11
+ export declare const OATHWEAVE_REQUEST_TIMEOUT_MS = 3000;
12
+ /** Oathweave access tokens live at most fifteen minutes. */
13
+ export declare const OATHWEAVE_MAX_LIFETIME_SEC = 900;
14
+ export declare const OATHWEAVE_DEFAULT_CLOCK_TOLERANCE_SEC = 30;
15
+ /** Longest accepted compact token. Real Oathweave tokens are well under 4 KiB. */
16
+ export declare const OATHWEAVE_MAX_TOKEN_LENGTH = 16384;
17
+ export interface OathweaveVerifierOptions extends EndpointOptions {
18
+ /** Accepted issuer, or issuers. Each must be an https URL. */
19
+ readonly issuer: string | readonly string[];
20
+ /** This service's resource audience, for example `hivemem`. Matched exactly. */
21
+ readonly audience: string;
22
+ /** The issuer's JWKS endpoint. Required unless `keys` is given. */
23
+ readonly jwksUrl?: string;
24
+ /**
25
+ * The userinfo endpoint. When set, user tokens are also checked online, so a signed-out
26
+ * session stops working within the cache TTL. Recommended for every product.
27
+ */
28
+ readonly userinfoUrl?: string;
29
+ /** A local JWKS or a jose key resolver, instead of fetching `jwksUrl`. */
30
+ readonly keys?: JSONWebKeySet | JWTVerifyGetKey;
31
+ /** Which principals `verify()` accepts by default. Defaults to `user`. */
32
+ readonly accept?: AcceptPrincipal;
33
+ /** Seconds of clock skew tolerated on `exp`, `nbf` and `iat`. Defaults to 30. */
34
+ readonly clockToleranceSec?: number;
35
+ /** Largest `exp - iat` and token age in seconds. Defaults to 900. */
36
+ readonly maxLifetimeSec?: number;
37
+ readonly fetch?: typeof fetch;
38
+ /** Clock in epoch milliseconds. */
39
+ readonly now?: () => number;
40
+ readonly requestTimeoutMs?: number;
41
+ /** How long fetched keys are trusted before a refetch. Defaults to 10 minutes. */
42
+ readonly jwksCacheMaxAgeMs?: number;
43
+ /** Minimum time between refetches triggered by an unknown `kid` (key rotation). Defaults to 30 s. */
44
+ readonly jwksCooldownMs?: number;
45
+ readonly sessionCacheTtlMs?: number;
46
+ readonly sessionCacheMax?: number;
47
+ }
48
+ export interface VerifyOptions {
49
+ /** Override the verifier's default `accept` for this call. */
50
+ readonly accept?: AcceptPrincipal;
51
+ }
52
+ export interface OathweaveVerifier {
53
+ readonly issuers: readonly string[];
54
+ readonly audience: string;
55
+ verify(token: string, options?: VerifyOptions): Promise<OathweavePrincipal>;
56
+ verifyUser(token: string): Promise<OathweaveUserPrincipal>;
57
+ verifyService(token: string): Promise<OathweaveServicePrincipal>;
58
+ /** Verify an `Authorization` header value. A missing header is `missing_token`. */
59
+ verifyAuthorization(header: string | null | undefined, options?: VerifyOptions): Promise<OathweavePrincipal>;
60
+ /** Forget cached session answers, for example after a sign-out event. */
61
+ clearSessionCache(): void;
62
+ }
63
+ /**
64
+ * The token in an `Authorization: Bearer` header (RFC 6750). Undefined when there is no
65
+ * header; throws `malformed_token` when there is one but it is not a single bearer token.
66
+ */
67
+ export declare function parseBearerToken(header: string | null | undefined): string | undefined;
68
+ /** Create a verifier. Throws a TypeError at startup when the configuration is unsafe. */
69
+ export declare function createOathweaveVerifier(options: OathweaveVerifierOptions): OathweaveVerifier;