@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,80 @@
1
+ /**
2
+ * A small structured JSON logger. One line per entry, secrets redacted, and the current
3
+ * request ID and trace ID added automatically from the request context.
4
+ */
5
+ import { getRequestContext } from '../context/index.js';
6
+ import { redact } from './redact.js';
7
+ export { redact, redactHeaders, redactText, isSensitiveKey, REDACTED } from './redact.js';
8
+ export const LOG_LEVELS = ['trace', 'debug', 'info', 'warn', 'error', 'fatal'];
9
+ const SEVERITY = { trace: 10, debug: 20, info: 30, warn: 40, error: 50, fatal: 60, silent: 100 };
10
+ function defaultLevel() {
11
+ const value = typeof process !== 'undefined' ? process.env['LOG_LEVEL']?.toLowerCase() : undefined;
12
+ return LOG_LEVELS.includes(value) ? value : 'info';
13
+ }
14
+ function serializeErrors(fields, stacks) {
15
+ const result = {};
16
+ for (const [key, value] of Object.entries(fields)) {
17
+ if (value instanceof Error) {
18
+ const code = value.code;
19
+ const status = value.status;
20
+ result[key] = {
21
+ name: value.name,
22
+ message: value.message,
23
+ ...(typeof code === 'string' || typeof code === 'number' ? { code } : {}),
24
+ ...(typeof status === 'number' ? { status } : {}),
25
+ ...(stacks && value.stack ? { stack: value.stack } : {}),
26
+ };
27
+ }
28
+ else
29
+ result[key] = value;
30
+ }
31
+ return result;
32
+ }
33
+ function safeStringify(value) {
34
+ try {
35
+ return JSON.stringify(value, (_key, item) => (typeof item === 'bigint' ? item.toString() : item));
36
+ }
37
+ catch {
38
+ return JSON.stringify({ level: 'error', msg: 'log entry could not be serialised' });
39
+ }
40
+ }
41
+ export function createLogger(options = {}) {
42
+ const level = options.level ?? defaultLevel();
43
+ const threshold = SEVERITY[level];
44
+ const write = options.write ?? ((line) => { process.stdout.write(`${line}\n`); });
45
+ const shouldRedact = options.redact ?? true;
46
+ const now = options.now ?? Date.now;
47
+ const context = options.context ?? getRequestContext;
48
+ const bindings = options.bindings ?? {};
49
+ const emit = (entryLevel, message, fields) => {
50
+ if (SEVERITY[entryLevel] < threshold)
51
+ return;
52
+ const current = context();
53
+ const merged = {
54
+ ...bindings,
55
+ ...(current ? current.bindings : {}),
56
+ ...serializeErrors({ ...fields }, options.stacks ?? false),
57
+ };
58
+ // Reserved fields win over bindings so a caller cannot forge them.
59
+ const entry = {
60
+ ...(shouldRedact ? redact(merged, { keys: options.redactKeys ?? [] }) : merged),
61
+ time: new Date(now()).toISOString(),
62
+ level: entryLevel,
63
+ msg: shouldRedact ? redact(message) : message,
64
+ ...(current ? { requestId: current.requestId, traceId: current.trace.traceId } : {}),
65
+ };
66
+ write(safeStringify(entry));
67
+ };
68
+ const logger = {
69
+ level,
70
+ trace: (message, fields) => emit('trace', message, fields),
71
+ debug: (message, fields) => emit('debug', message, fields),
72
+ info: (message, fields) => emit('info', message, fields),
73
+ warn: (message, fields) => emit('warn', message, fields),
74
+ error: (message, fields) => emit('error', message, fields),
75
+ fatal: (message, fields) => emit('fatal', message, fields),
76
+ child: childBindings => createLogger({ ...options, level, bindings: { ...bindings, ...childBindings } }),
77
+ isLevelEnabled: candidate => SEVERITY[candidate] >= threshold,
78
+ };
79
+ return logger;
80
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Secret redaction for logs and diagnostics. Follows the suite pattern (secret-looking keys
3
+ * are masked, strings are scrubbed of credentials) and adds headers, JWTs, URL credentials,
4
+ * provider token formats and cycle-safe traversal.
5
+ */
6
+ export declare const REDACTED = "[REDACTED]";
7
+ /** True for a key whose value should never be logged. */
8
+ export declare function isSensitiveKey(key: string): boolean;
9
+ /** Scrub credentials out of free text. */
10
+ export declare function redactText(value: string): string;
11
+ export interface RedactOptions {
12
+ /** Extra keys to mask, compared case-insensitively and exactly. */
13
+ readonly keys?: readonly string[];
14
+ }
15
+ /**
16
+ * A deep copy of `value` with secrets masked. Sensitive keys holding strings, objects or
17
+ * arrays are replaced by `[REDACTED]`; numbers and booleans under such keys (token counts,
18
+ * `hasPassword` flags) are kept. Strings everywhere are scrubbed with `redactText()`.
19
+ * Errors become `{ name, message, code? }`; cycles become `[Circular]`.
20
+ */
21
+ export declare function redact<T>(value: T, options?: RedactOptions): T;
22
+ /** Mask sensitive request or response headers, keeping the others. */
23
+ export declare function redactHeaders(headers: Readonly<Record<string, string | readonly string[] | undefined>> | Headers): Record<string, unknown>;
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Secret redaction for logs and diagnostics. Follows the suite pattern (secret-looking keys
3
+ * are masked, strings are scrubbed of credentials) and adds headers, JWTs, URL credentials,
4
+ * provider token formats and cycle-safe traversal.
5
+ */
6
+ export const REDACTED = '[REDACTED]';
7
+ const MAX_DEPTH = 10;
8
+ /** Key fragments, compared after lowercasing and removing `-`, `_` and `.`. */
9
+ const SENSITIVE_KEY_PARTS = [
10
+ 'authorization', 'cookie', 'password', 'passwd', 'passphrase', 'secret', 'token',
11
+ 'apikey', 'privatekey', 'accesskey', 'signingkey', 'credential', 'webhookurl', 'sessionid',
12
+ ];
13
+ /** Keys that end in "key" but are not secret. */
14
+ const NON_SECRET_KEYS = new Set(['publickey', 'idempotencykey', 'cachekey', 'sortkey', 'partitionkey', 'primarykey', 'foreignkey']);
15
+ /** True for a key whose value should never be logged. */
16
+ export function isSensitiveKey(key) {
17
+ const normalized = key.toLowerCase().replace(/[-_.\s]/g, '');
18
+ if (NON_SECRET_KEYS.has(normalized))
19
+ return false;
20
+ if (SENSITIVE_KEY_PARTS.some(part => normalized.includes(part)))
21
+ return true;
22
+ // key, apiKey, signing_key, x-api-key, keyMaterial...; not "keys", "monkey" is accepted collateral.
23
+ return normalized === 'key' || normalized.endsWith('key') || normalized.startsWith('keymaterial');
24
+ }
25
+ const TEXT_RULES = [
26
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g, REDACTED],
27
+ // Authorization schemes.
28
+ [/\b(Bearer|Basic|Token)\s+[A-Za-z0-9._~+/=-]{4,}/gi, `$1 ${REDACTED}`],
29
+ // JWT / JWS compact serialisations (header.payload.signature, signature may be empty).
30
+ [/\beyJ[A-Za-z0-9_-]{5,}\.eyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]*/g, REDACTED],
31
+ // Provider key formats.
32
+ [/\bsk-[A-Za-z0-9_-]{12,}/g, REDACTED],
33
+ [/\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})\b/g, REDACTED],
34
+ [/\bnpm_[A-Za-z0-9]{20,}\b/g, REDACTED],
35
+ [/\bxox[baprs]-[A-Za-z0-9-]{10,}/g, REDACTED],
36
+ [/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, REDACTED],
37
+ // Credentials in URLs.
38
+ [/\b([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]+@/gi, `$1${REDACTED}@`],
39
+ // Secret-looking query parameters and key=value / key: value pairs.
40
+ [/([?&](?:access_token|refresh_token|id_token|token|code|client_secret|api_key|apikey|key|password|sig|signature)=)[^&#\s]+/gi, `$1${REDACTED}`],
41
+ [/\b((?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|secret|client[_-]?secret|password|passwd|private[_-]?key)["']?\s*[:=]\s*["']?)[^"',\s}&]+/gi, `$1${REDACTED}`],
42
+ ];
43
+ /** Scrub credentials out of free text. */
44
+ export function redactText(value) {
45
+ let text = value;
46
+ for (const [pattern, replacement] of TEXT_RULES)
47
+ text = text.replace(pattern, replacement);
48
+ return text;
49
+ }
50
+ /**
51
+ * A deep copy of `value` with secrets masked. Sensitive keys holding strings, objects or
52
+ * arrays are replaced by `[REDACTED]`; numbers and booleans under such keys (token counts,
53
+ * `hasPassword` flags) are kept. Strings everywhere are scrubbed with `redactText()`.
54
+ * Errors become `{ name, message, code? }`; cycles become `[Circular]`.
55
+ */
56
+ export function redact(value, options = {}) {
57
+ const extra = new Set((options.keys ?? []).map(key => key.toLowerCase()));
58
+ const seen = new WeakSet();
59
+ const visit = (item, depth) => {
60
+ if (typeof item === 'string')
61
+ return redactText(item);
62
+ if (typeof item === 'bigint')
63
+ return item.toString();
64
+ if (typeof item !== 'object' || item === null)
65
+ return item;
66
+ if (seen.has(item))
67
+ return '[Circular]';
68
+ if (depth >= MAX_DEPTH)
69
+ return '[Truncated]';
70
+ seen.add(item);
71
+ try {
72
+ if (item instanceof Date)
73
+ return Number.isNaN(item.getTime()) ? null : item.toISOString();
74
+ if (item instanceof Error) {
75
+ const code = item.code;
76
+ return { name: item.name, message: redactText(item.message), ...(typeof code === 'string' || typeof code === 'number' ? { code } : {}) };
77
+ }
78
+ if (typeof item.forEach === 'function' && typeof item.entries === 'function'
79
+ && typeof item.get === 'function' && !(item instanceof Map)) {
80
+ // fetch Headers and similar.
81
+ const record = {};
82
+ item.forEach((headerValue, key) => { record[key] = headerValue; });
83
+ return visit(record, depth);
84
+ }
85
+ if (item instanceof Map)
86
+ return visit(Object.fromEntries(item), depth);
87
+ if (item instanceof Set)
88
+ return visit([...item], depth);
89
+ if (ArrayBuffer.isView(item) || item instanceof ArrayBuffer)
90
+ return `[Binary ${item.byteLength} bytes]`;
91
+ if (Array.isArray(item))
92
+ return item.map(element => visit(element, depth + 1));
93
+ const result = {};
94
+ for (const [key, child] of Object.entries(item)) {
95
+ const sensitive = isSensitiveKey(key) || extra.has(key.toLowerCase());
96
+ result[key] = sensitive && (typeof child === 'string' || (typeof child === 'object' && child !== null))
97
+ ? REDACTED
98
+ : visit(child, depth + 1);
99
+ }
100
+ return result;
101
+ }
102
+ finally {
103
+ seen.delete(item);
104
+ }
105
+ };
106
+ return visit(value, 0);
107
+ }
108
+ /** Mask sensitive request or response headers, keeping the others. */
109
+ export function redactHeaders(headers) {
110
+ return redact(headers);
111
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * `node:http` adapters: the same request context, Oathweave auth, error envelope and health
3
+ * routes as `/hono`, for services built on a plain `http.createServer()`.
4
+ *
5
+ * const server = http.createServer(hiveHandler(async (req, res) => {
6
+ * const principal = await authenticate(req, verifier);
7
+ * sendJson(res, 200, { hello: principal.subject });
8
+ * }, { health: { checks }, logger }));
9
+ */
10
+ import type { IncomingMessage, RequestListener, ServerResponse } from 'node:http';
11
+ import { type CreateRequestContextOptions, type RequestContext } from '../context/index.js';
12
+ import { type Health, type HealthOptions } from '../health/index.js';
13
+ import type { Logger } from '../log/index.js';
14
+ import type { OathweavePrincipal } from '../oathweave/claims.js';
15
+ import type { OathweaveVerifier, VerifyOptions } from '../oathweave/verifier.js';
16
+ export declare function requestContextFor(req: IncomingMessage, options?: CreateRequestContextOptions): RequestContext;
17
+ /** Verify the request's `Authorization: Bearer` header and record the principal on the context. */
18
+ export declare function authenticate(req: IncomingMessage, verifier: OathweaveVerifier, options?: VerifyOptions): Promise<OathweavePrincipal>;
19
+ /** Send a JSON body with `no-store` caching. */
20
+ export declare function sendJson(res: ServerResponse, status: number, body: unknown, headers?: Readonly<Record<string, string>>): void;
21
+ export interface SendErrorOptions {
22
+ readonly requestId?: string | undefined;
23
+ readonly logger?: Pick<Logger, 'error'>;
24
+ }
25
+ /** Send any thrown value as the error envelope. Non-HiveErrors become a logged, generic 500. */
26
+ export declare function sendError(res: ServerResponse, error: unknown, options?: SendErrorOptions): void;
27
+ /** Serve a health path. Returns true when `req` was a health request and has been answered. */
28
+ export declare function handleHealth(req: IncomingMessage, res: ServerResponse, health: Health): Promise<boolean>;
29
+ export interface HiveHandlerOptions extends CreateRequestContextOptions {
30
+ /** Serve health endpoints before `handler` runs. */
31
+ readonly health?: HealthOptions | Health;
32
+ readonly logger?: Pick<Logger, 'error'>;
33
+ /** Echo `x-request-id` on every response. Defaults to true. */
34
+ readonly responseHeader?: boolean;
35
+ }
36
+ type Handler = (req: IncomingMessage, res: ServerResponse) => unknown;
37
+ /**
38
+ * Wrap a handler with the request context, health routes and the error envelope. Anything the
39
+ * handler throws (or rejects with) is sent as an envelope; a handler that returns without
40
+ * responding gets a 404 envelope.
41
+ */
42
+ export declare function hiveHandler(handler: Handler, options?: HiveHandlerOptions): RequestListener;
43
+ export {};
@@ -0,0 +1,88 @@
1
+ import { createRequestContext, getRequestContext, runWithRequestContext, } from '../context/index.js';
2
+ import { HiveError, toErrorResponse } from '../errors/index.js';
3
+ import { createHealth } from '../health/index.js';
4
+ import { OathweaveError } from '../oathweave/errors.js';
5
+ /** The request context for `req`, created on first use and cached on the request. */
6
+ const contexts = new WeakMap();
7
+ export function requestContextFor(req, options = {}) {
8
+ let context = contexts.get(req);
9
+ if (!context) {
10
+ context = createRequestContext(req.headers, options);
11
+ contexts.set(req, context);
12
+ }
13
+ return context;
14
+ }
15
+ /** Verify the request's `Authorization: Bearer` header and record the principal on the context. */
16
+ export async function authenticate(req, verifier, options = {}) {
17
+ const header = req.headers.authorization;
18
+ if (Array.isArray(header))
19
+ throw new OathweaveError('malformed_token');
20
+ const principal = await verifier.verifyAuthorization(header, options);
21
+ const context = contexts.get(req) ?? getRequestContext();
22
+ if (context)
23
+ context.principal = principal;
24
+ return principal;
25
+ }
26
+ /** Send a JSON body with `no-store` caching. */
27
+ export function sendJson(res, status, body, headers = {}) {
28
+ if (res.headersSent) {
29
+ res.destroy();
30
+ return;
31
+ }
32
+ const payload = JSON.stringify(body);
33
+ res.writeHead(status, {
34
+ 'content-type': 'application/json; charset=utf-8',
35
+ 'cache-control': 'no-store',
36
+ 'content-length': Buffer.byteLength(payload),
37
+ ...headers,
38
+ });
39
+ res.end(res.req?.method === 'HEAD' ? undefined : payload);
40
+ }
41
+ /** Send any thrown value as the error envelope. Non-HiveErrors become a logged, generic 500. */
42
+ export function sendError(res, error, options = {}) {
43
+ const response = toErrorResponse(error, {
44
+ requestId: options.requestId ?? getRequestContext()?.requestId,
45
+ onUnexpected: unexpected => options.logger?.error('unhandled error', { err: unexpected }),
46
+ });
47
+ sendJson(res, response.status, response.body, response.headers);
48
+ }
49
+ /** Serve a health path. Returns true when `req` was a health request and has been answered. */
50
+ export async function handleHealth(req, res, health) {
51
+ if (req.method !== 'GET' && req.method !== 'HEAD')
52
+ return false;
53
+ const pathname = new URL(req.url ?? '/', 'http://localhost').pathname;
54
+ const pending = health.handle(pathname);
55
+ if (!pending)
56
+ return false;
57
+ const result = await pending;
58
+ sendJson(res, result.status, result.body, result.headers);
59
+ return true;
60
+ }
61
+ function isHealth(value) {
62
+ return typeof value.handle === 'function';
63
+ }
64
+ /**
65
+ * Wrap a handler with the request context, health routes and the error envelope. Anything the
66
+ * handler throws (or rejects with) is sent as an envelope; a handler that returns without
67
+ * responding gets a 404 envelope.
68
+ */
69
+ export function hiveHandler(handler, options = {}) {
70
+ const health = options.health ? (isHealth(options.health) ? options.health : createHealth(options.health)) : undefined;
71
+ return (req, res) => {
72
+ const context = requestContextFor(req, options);
73
+ if (options.responseHeader ?? true)
74
+ res.setHeader('x-request-id', context.requestId);
75
+ void runWithRequestContext(context, async () => {
76
+ try {
77
+ if (health && await handleHealth(req, res, health))
78
+ return;
79
+ await handler(req, res);
80
+ if (!res.headersSent && !res.writableEnded)
81
+ sendError(res, HiveError.notFound(), { requestId: context.requestId });
82
+ }
83
+ catch (error) {
84
+ sendError(res, error, { requestId: context.requestId, ...(options.logger ? { logger: options.logger } : {}) });
85
+ }
86
+ });
87
+ };
88
+ }
@@ -0,0 +1,63 @@
1
+ /** Pure claim checks and principal extraction for verified Oathweave access tokens. */
2
+ import type { JWTPayload } from 'jose';
3
+ export type PrincipalKind = 'user' | 'service';
4
+ /** Which principals a verifier or route accepts. */
5
+ export type AcceptPrincipal = PrincipalKind | 'any';
6
+ interface PrincipalBase {
7
+ readonly kind: PrincipalKind;
8
+ readonly subject: string;
9
+ /** The issuer that signed this token (one of the configured issuers). */
10
+ readonly issuer: string;
11
+ readonly clientId: string;
12
+ /** The token's `jti`. */
13
+ readonly tokenId: string;
14
+ readonly roles: readonly string[];
15
+ readonly permissions: readonly string[];
16
+ /** From the space-separated `scope` claim (or an `scp` array). */
17
+ readonly scopes: readonly string[];
18
+ /** Epoch seconds. */
19
+ readonly issuedAt: number;
20
+ /** Epoch seconds. Streams must close by this time. */
21
+ readonly expiresAt: number;
22
+ /** All verified claims, for product-specific fields. */
23
+ readonly claims: Readonly<JWTPayload>;
24
+ }
25
+ /** A person, signed in through a client, with an Oathweave session. */
26
+ export interface OathweaveUserPrincipal extends PrincipalBase {
27
+ readonly kind: 'user';
28
+ readonly sessionId: string;
29
+ /** From userinfo `name` when the session was checked online, else token `name`, `username` or the subject. */
30
+ readonly displayName: string;
31
+ /** When the person last proved their identity (epoch seconds). Undefined means unknown, never recent. */
32
+ readonly authTime: number | undefined;
33
+ /** True when the session was confirmed active against userinfo for this call. */
34
+ readonly sessionChecked: boolean;
35
+ }
36
+ /** A machine client (client_credentials): its subject is its client ID, and it has no session. */
37
+ export interface OathweaveServicePrincipal extends PrincipalBase {
38
+ readonly kind: 'service';
39
+ }
40
+ export type OathweavePrincipal = OathweaveUserPrincipal | OathweaveServicePrincipal;
41
+ /** The client claim: Oathweave's `clientId`, or RFC 9068 `client_id`. Both present must agree. */
42
+ export declare function clientIdOf(payload: JWTPayload): string | undefined;
43
+ /** Scopes from `scope` (space-separated) or `scp` (array or string), de-duplicated. */
44
+ export declare function extractScopes(payload: JWTPayload): string[];
45
+ /** Roles; an absent claim is no roles. A present claim that is not a string array is invalid. */
46
+ export declare function extractRoles(payload: JWTPayload): string[] | undefined;
47
+ /** A service token is one whose client ID equals its subject. */
48
+ export declare function principalKindOf(payload: JWTPayload): PrincipalKind;
49
+ export interface ClaimPolicy {
50
+ readonly accept: AcceptPrincipal;
51
+ /** Largest allowed `exp - iat`, in seconds. */
52
+ readonly maxLifetimeSec: number;
53
+ }
54
+ /**
55
+ * Check the Oathweave claim rules on a payload whose signature, issuer, audience and time
56
+ * claims jose already verified, and build the principal (without session data).
57
+ *
58
+ * Users: `actorType` USER, a session, a client that is not the subject.
59
+ * Services: subject equals client ID, `actorType` SERVICE_ACCOUNT, `token_type` access, no session.
60
+ * Both: `jti`, string-array `permissions`, `exp - iat` within the lifetime limit.
61
+ */
62
+ export declare function principalFromClaims(payload: JWTPayload, policy: ClaimPolicy): OathweavePrincipal;
63
+ export {};
@@ -0,0 +1,91 @@
1
+ import { OathweaveError } from './errors.js';
2
+ function stringArray(value) {
3
+ return Array.isArray(value) && value.every(item => typeof item === 'string');
4
+ }
5
+ function nonEmpty(value) {
6
+ return typeof value === 'string' && value.length > 0;
7
+ }
8
+ /** The client claim: Oathweave's `clientId`, or RFC 9068 `client_id`. Both present must agree. */
9
+ export function clientIdOf(payload) {
10
+ const camel = payload['clientId'];
11
+ const snake = payload['client_id'];
12
+ if (camel !== undefined && snake !== undefined && camel !== snake)
13
+ return undefined;
14
+ const value = camel ?? snake;
15
+ return typeof value === 'string' ? value : undefined;
16
+ }
17
+ /** Scopes from `scope` (space-separated) or `scp` (array or string), de-duplicated. */
18
+ export function extractScopes(payload) {
19
+ const raw = payload['scope'] ?? payload['scp'];
20
+ const list = typeof raw === 'string' ? raw.split(/\s+/) : stringArray(raw) ? raw : [];
21
+ return [...new Set(list.map(item => item.trim()).filter(Boolean))];
22
+ }
23
+ /** Roles; an absent claim is no roles. A present claim that is not a string array is invalid. */
24
+ export function extractRoles(payload) {
25
+ const roles = payload['roles'];
26
+ if (roles === undefined)
27
+ return [];
28
+ return stringArray(roles) ? [...roles] : undefined;
29
+ }
30
+ /** A service token is one whose client ID equals its subject. */
31
+ export function principalKindOf(payload) {
32
+ const clientId = clientIdOf(payload);
33
+ return clientId !== undefined && clientId === payload.sub ? 'service' : 'user';
34
+ }
35
+ function validAuthTime(value) {
36
+ return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : undefined;
37
+ }
38
+ /**
39
+ * Check the Oathweave claim rules on a payload whose signature, issuer, audience and time
40
+ * claims jose already verified, and build the principal (without session data).
41
+ *
42
+ * Users: `actorType` USER, a session, a client that is not the subject.
43
+ * Services: subject equals client ID, `actorType` SERVICE_ACCOUNT, `token_type` access, no session.
44
+ * Both: `jti`, string-array `permissions`, `exp - iat` within the lifetime limit.
45
+ */
46
+ export function principalFromClaims(payload, policy) {
47
+ const { iat, exp, sub, jti, iss } = payload;
48
+ if (typeof iat !== 'number' || typeof exp !== 'number' || exp <= iat || exp - iat > policy.maxLifetimeSec) {
49
+ throw new OathweaveError('invalid_lifetime');
50
+ }
51
+ if (!nonEmpty(sub) || !nonEmpty(jti) || !nonEmpty(iss))
52
+ throw new OathweaveError('invalid_claims');
53
+ const permissions = payload['permissions'];
54
+ const roles = extractRoles(payload);
55
+ const clientId = clientIdOf(payload);
56
+ if (!stringArray(permissions) || roles === undefined || clientId === undefined)
57
+ throw new OathweaveError('invalid_claims');
58
+ const kind = principalKindOf(payload);
59
+ if (policy.accept !== 'any' && policy.accept !== kind)
60
+ throw new OathweaveError('wrong_principal_kind', { details: { kind } });
61
+ const base = {
62
+ subject: sub,
63
+ issuer: iss,
64
+ clientId,
65
+ tokenId: jti,
66
+ roles,
67
+ permissions: [...permissions],
68
+ scopes: extractScopes(payload),
69
+ issuedAt: iat,
70
+ expiresAt: exp,
71
+ claims: payload,
72
+ };
73
+ if (kind === 'service') {
74
+ if (payload['actorType'] !== 'SERVICE_ACCOUNT' || payload['token_type'] !== 'access' || payload['sessionId'] !== undefined) {
75
+ throw new OathweaveError('invalid_claims');
76
+ }
77
+ return { kind, ...base };
78
+ }
79
+ const sessionId = payload['sessionId'];
80
+ if (payload['actorType'] !== 'USER' || !nonEmpty(sessionId) || !nonEmpty(clientId))
81
+ throw new OathweaveError('invalid_claims');
82
+ const name = payload['name'] ?? payload['username'];
83
+ return {
84
+ kind,
85
+ ...base,
86
+ sessionId,
87
+ displayName: nonEmpty(name) ? name : sub,
88
+ authTime: validAuthTime(payload['auth_time']),
89
+ sessionChecked: false,
90
+ };
91
+ }
@@ -0,0 +1,88 @@
1
+ import type { JSONWebKeySet } from 'jose';
2
+ import type { PrincipalKind } from './claims.js';
3
+ import type { OathweaveFailureReason } from './errors.js';
4
+ export type ConformanceExpectation = {
5
+ readonly outcome: 'accept';
6
+ readonly kind: PrincipalKind;
7
+ readonly subject: string;
8
+ readonly clientId: string;
9
+ readonly roles: readonly string[];
10
+ readonly permissions: readonly string[];
11
+ readonly scopes: readonly string[];
12
+ } | {
13
+ readonly outcome: 'reject';
14
+ readonly status: 401 | 403 | 503;
15
+ readonly reason: OathweaveFailureReason;
16
+ };
17
+ export interface ConformanceVector {
18
+ readonly name: string;
19
+ readonly description: string;
20
+ /** The principal kind the token claims to be, for products with separate user and service verifiers. */
21
+ readonly principal: PrincipalKind;
22
+ readonly token: string;
23
+ readonly expect: ConformanceExpectation;
24
+ }
25
+ export interface ConformanceVectors {
26
+ readonly schema: 1;
27
+ readonly issuer: string;
28
+ readonly audience: string;
29
+ readonly jwksUrl: string;
30
+ readonly userinfoUrl: string;
31
+ /** The fixed evaluation time, RFC 3339. */
32
+ readonly now: string;
33
+ readonly jwks: JSONWebKeySet;
34
+ readonly vectors: readonly ConformanceVector[];
35
+ }
36
+ export interface ConformanceContext {
37
+ readonly issuer: string;
38
+ readonly audience: string;
39
+ readonly jwks: JSONWebKeySet;
40
+ readonly jwksUrl: string;
41
+ readonly userinfoUrl: string;
42
+ /** The fixed clock. Verifiers must evaluate time claims at this instant. */
43
+ readonly now: Date;
44
+ readonly nowMs: number;
45
+ readonly principal: PrincipalKind;
46
+ readonly vector: ConformanceVector;
47
+ /**
48
+ * A fetch that serves `jwksUrl` and `userinfoUrl` for the vectors, for verifiers that only
49
+ * take URLs. Userinfo answers with the bearer token's own subject, as an active session would.
50
+ */
51
+ readonly fetch: typeof fetch;
52
+ }
53
+ /** Verify `token` with the product's verifier. Resolve on acceptance, reject on refusal. */
54
+ export type ConformanceVerify = (token: string, context: ConformanceContext) => Promise<unknown>;
55
+ export interface ConformanceResult {
56
+ readonly name: string;
57
+ readonly passed: boolean;
58
+ readonly expected: ConformanceExpectation['outcome'];
59
+ readonly actual: 'accept' | 'reject';
60
+ /** Why a vector failed. */
61
+ readonly problem?: string;
62
+ }
63
+ export interface ConformanceReport {
64
+ readonly passed: number;
65
+ readonly failed: number;
66
+ readonly results: readonly ConformanceResult[];
67
+ }
68
+ export interface ConformanceOptions {
69
+ /** Use these vectors instead of the shipped file. */
70
+ readonly vectors?: ConformanceVectors;
71
+ /** Also require a matching `reason` on refusals (HiveCommon's OathweaveError has one). */
72
+ readonly strictReasons?: boolean;
73
+ /** Throw when any vector fails. Defaults to true, so a test fails with a readable message. */
74
+ readonly throwOnFailure?: boolean;
75
+ /** Run only vectors with these names. */
76
+ readonly only?: readonly string[];
77
+ }
78
+ /** Load the shipped vectors. */
79
+ export declare function loadOathweaveVectors(): Promise<ConformanceVectors>;
80
+ /**
81
+ * Run every vector through `verify` at the vectors' fixed clock. A vector expecting acceptance
82
+ * passes when `verify` resolves (and any `kind`, `subject`, `roles`, `permissions` or `scopes`
83
+ * on the result match); one expecting refusal passes when it rejects (and any numeric
84
+ * `status` on the error matches, plus `reason` with `strictReasons`).
85
+ */
86
+ export declare function runOathweaveConformance(verify: ConformanceVerify, options?: ConformanceOptions): Promise<ConformanceReport>;
87
+ /** A `ConformanceVerify` for this package's own verifier, for products that adopt it as is. */
88
+ export declare function hiveCommonConformanceVerify(): ConformanceVerify;