@hivemagnus/hive-common 0.0.0-stage → 0.2.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/CHANGELOG.md +65 -0
- package/LICENSE +202 -0
- package/NOTICE +11 -0
- package/README.md +439 -2
- package/dist/context/index.d.ts +98 -0
- package/dist/context/index.js +191 -0
- package/dist/contracts/index.d.ts +117 -0
- package/dist/contracts/index.js +228 -0
- package/dist/errors/index.d.ts +91 -0
- package/dist/errors/index.js +309 -0
- package/dist/events/index.d.ts +107 -0
- package/dist/events/index.js +232 -0
- package/dist/health/index.d.ts +101 -0
- package/dist/health/index.js +126 -0
- package/dist/hono/index.d.ts +67 -0
- package/dist/hono/index.js +108 -0
- package/dist/log/index.d.ts +39 -0
- package/dist/log/index.js +80 -0
- package/dist/log/redact.d.ts +23 -0
- package/dist/log/redact.js +111 -0
- package/dist/node-http/index.d.ts +46 -0
- package/dist/node-http/index.js +91 -0
- package/dist/oathweave/claims.d.ts +79 -0
- package/dist/oathweave/claims.js +99 -0
- package/dist/oathweave/conformance.d.ts +88 -0
- package/dist/oathweave/conformance.js +124 -0
- package/dist/oathweave/endpoint.d.ts +15 -0
- package/dist/oathweave/endpoint.js +35 -0
- package/dist/oathweave/errors.d.ts +19 -0
- package/dist/oathweave/errors.js +70 -0
- package/dist/oathweave/index.d.ts +10 -0
- package/dist/oathweave/index.js +10 -0
- package/dist/oathweave/links.d.ts +63 -0
- package/dist/oathweave/links.js +138 -0
- package/dist/oathweave/stream.d.ts +26 -0
- package/dist/oathweave/stream.js +46 -0
- package/dist/oathweave/verifier.d.ts +83 -0
- package/dist/oathweave/verifier.js +237 -0
- package/dist/registry/index.d.ts +138 -0
- package/dist/registry/index.js +175 -0
- package/package.json +126 -4
- package/test-vectors/README.md +27 -0
- package/test-vectors/oathweave/jwks.json +12 -0
- package/test-vectors/oathweave/vectors.json +174 -0
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
/**
|
|
28
|
+
* Serve a health path. Returns true when `req` was a health request and has been answered.
|
|
29
|
+
* The liveness body is the one `health` was created with (see `liveBody`).
|
|
30
|
+
*/
|
|
31
|
+
export declare function handleHealth(req: IncomingMessage, res: ServerResponse, health: Health<object>): Promise<boolean>;
|
|
32
|
+
export interface HiveHandlerOptions extends CreateRequestContextOptions {
|
|
33
|
+
/** Serve health endpoints before `handler` runs. */
|
|
34
|
+
readonly health?: HealthOptions<object> | Health<object>;
|
|
35
|
+
readonly logger?: Pick<Logger, 'error'>;
|
|
36
|
+
/** Echo `x-request-id` on every response. Defaults to true. */
|
|
37
|
+
readonly responseHeader?: boolean;
|
|
38
|
+
}
|
|
39
|
+
type Handler = (req: IncomingMessage, res: ServerResponse) => unknown;
|
|
40
|
+
/**
|
|
41
|
+
* Wrap a handler with the request context, health routes and the error envelope. Anything the
|
|
42
|
+
* handler throws (or rejects with) is sent as an envelope; a handler that returns without
|
|
43
|
+
* responding gets a 404 envelope.
|
|
44
|
+
*/
|
|
45
|
+
export declare function hiveHandler(handler: Handler, options?: HiveHandlerOptions): RequestListener;
|
|
46
|
+
export {};
|
|
@@ -0,0 +1,91 @@
|
|
|
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
|
+
/**
|
|
50
|
+
* Serve a health path. Returns true when `req` was a health request and has been answered.
|
|
51
|
+
* The liveness body is the one `health` was created with (see `liveBody`).
|
|
52
|
+
*/
|
|
53
|
+
export async function handleHealth(req, res, health) {
|
|
54
|
+
if (req.method !== 'GET' && req.method !== 'HEAD')
|
|
55
|
+
return false;
|
|
56
|
+
const pathname = new URL(req.url ?? '/', 'http://localhost').pathname;
|
|
57
|
+
const pending = health.handle(pathname);
|
|
58
|
+
if (!pending)
|
|
59
|
+
return false;
|
|
60
|
+
const result = await pending;
|
|
61
|
+
sendJson(res, result.status, result.body, result.headers);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
function isHealth(value) {
|
|
65
|
+
return typeof value.handle === 'function';
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Wrap a handler with the request context, health routes and the error envelope. Anything the
|
|
69
|
+
* handler throws (or rejects with) is sent as an envelope; a handler that returns without
|
|
70
|
+
* responding gets a 404 envelope.
|
|
71
|
+
*/
|
|
72
|
+
export function hiveHandler(handler, options = {}) {
|
|
73
|
+
const health = options.health ? (isHealth(options.health) ? options.health : createHealth(options.health)) : undefined;
|
|
74
|
+
return (req, res) => {
|
|
75
|
+
const context = requestContextFor(req, options);
|
|
76
|
+
if (options.responseHeader ?? true)
|
|
77
|
+
res.setHeader('x-request-id', context.requestId);
|
|
78
|
+
void runWithRequestContext(context, async () => {
|
|
79
|
+
try {
|
|
80
|
+
if (health && await handleHealth(req, res, health))
|
|
81
|
+
return;
|
|
82
|
+
await handler(req, res);
|
|
83
|
+
if (!res.headersSent && !res.writableEnded)
|
|
84
|
+
sendError(res, HiveError.notFound(), { requestId: context.requestId });
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
sendError(res, error, { requestId: context.requestId, ...(options.logger ? { logger: options.logger } : {}) });
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
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
|
+
/**
|
|
42
|
+
* Which claim names carry the client ID: Oathweave's `clientId`, RFC 9068 `client_id`, or
|
|
43
|
+
* `either` (the default).
|
|
44
|
+
*/
|
|
45
|
+
export type ClientIdClaim = 'clientId' | 'client_id' | 'either';
|
|
46
|
+
export declare const CLIENT_ID_CLAIMS: readonly ClientIdClaim[];
|
|
47
|
+
/**
|
|
48
|
+
* The client claim: Oathweave's `clientId`, or RFC 9068 `client_id`. Both present must agree,
|
|
49
|
+
* whichever name is accepted. With `clientId` or `client_id`, only that claim supplies the value.
|
|
50
|
+
*/
|
|
51
|
+
export declare function clientIdOf(payload: JWTPayload, claim?: ClientIdClaim): string | undefined;
|
|
52
|
+
/** Scopes from `scope` (space-separated) or `scp` (array or string), de-duplicated. */
|
|
53
|
+
export declare function extractScopes(payload: JWTPayload): string[];
|
|
54
|
+
/**
|
|
55
|
+
* Roles; an absent claim is no roles, or invalid when `required` is true. A present claim that
|
|
56
|
+
* is not a string array is invalid.
|
|
57
|
+
*/
|
|
58
|
+
export declare function extractRoles(payload: JWTPayload, required?: boolean): string[] | undefined;
|
|
59
|
+
/** A service token is one whose client ID equals its subject. */
|
|
60
|
+
export declare function principalKindOf(payload: JWTPayload, claim?: ClientIdClaim): PrincipalKind;
|
|
61
|
+
export interface ClaimPolicy {
|
|
62
|
+
readonly accept: AcceptPrincipal;
|
|
63
|
+
/** Largest allowed `exp - iat`, in seconds. */
|
|
64
|
+
readonly maxLifetimeSec: number;
|
|
65
|
+
/** Refuse a token without a `roles` claim. Defaults to false: an absent claim is no roles. */
|
|
66
|
+
readonly requireRoles?: boolean;
|
|
67
|
+
/** Which claim names supply the client ID. Defaults to `either`. */
|
|
68
|
+
readonly clientIdClaim?: ClientIdClaim;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Check the Oathweave claim rules on a payload whose signature, issuer, audience and time
|
|
72
|
+
* claims jose already verified, and build the principal (without session data).
|
|
73
|
+
*
|
|
74
|
+
* Users: `actorType` USER, a session, a client that is not the subject.
|
|
75
|
+
* Services: subject equals client ID, `actorType` SERVICE_ACCOUNT, `token_type` access, no session.
|
|
76
|
+
* Both: `jti`, string-array `permissions`, `exp - iat` within the lifetime limit.
|
|
77
|
+
*/
|
|
78
|
+
export declare function principalFromClaims(payload: JWTPayload, policy: ClaimPolicy): OathweavePrincipal;
|
|
79
|
+
export {};
|
|
@@ -0,0 +1,99 @@
|
|
|
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
|
+
export const CLIENT_ID_CLAIMS = ['clientId', 'client_id', 'either'];
|
|
9
|
+
/**
|
|
10
|
+
* The client claim: Oathweave's `clientId`, or RFC 9068 `client_id`. Both present must agree,
|
|
11
|
+
* whichever name is accepted. With `clientId` or `client_id`, only that claim supplies the value.
|
|
12
|
+
*/
|
|
13
|
+
export function clientIdOf(payload, claim = 'either') {
|
|
14
|
+
const camel = payload['clientId'];
|
|
15
|
+
const snake = payload['client_id'];
|
|
16
|
+
if (camel !== undefined && snake !== undefined && camel !== snake)
|
|
17
|
+
return undefined;
|
|
18
|
+
const value = claim === 'clientId' ? camel : claim === 'client_id' ? snake : camel ?? snake;
|
|
19
|
+
return typeof value === 'string' ? value : undefined;
|
|
20
|
+
}
|
|
21
|
+
/** Scopes from `scope` (space-separated) or `scp` (array or string), de-duplicated. */
|
|
22
|
+
export function extractScopes(payload) {
|
|
23
|
+
const raw = payload['scope'] ?? payload['scp'];
|
|
24
|
+
const list = typeof raw === 'string' ? raw.split(/\s+/) : stringArray(raw) ? raw : [];
|
|
25
|
+
return [...new Set(list.map(item => item.trim()).filter(Boolean))];
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Roles; an absent claim is no roles, or invalid when `required` is true. A present claim that
|
|
29
|
+
* is not a string array is invalid.
|
|
30
|
+
*/
|
|
31
|
+
export function extractRoles(payload, required = false) {
|
|
32
|
+
const roles = payload['roles'];
|
|
33
|
+
if (roles === undefined)
|
|
34
|
+
return required ? undefined : [];
|
|
35
|
+
return stringArray(roles) ? [...roles] : undefined;
|
|
36
|
+
}
|
|
37
|
+
/** A service token is one whose client ID equals its subject. */
|
|
38
|
+
export function principalKindOf(payload, claim = 'either') {
|
|
39
|
+
const clientId = clientIdOf(payload, claim);
|
|
40
|
+
return clientId !== undefined && clientId === payload.sub ? 'service' : 'user';
|
|
41
|
+
}
|
|
42
|
+
function validAuthTime(value) {
|
|
43
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : undefined;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Check the Oathweave claim rules on a payload whose signature, issuer, audience and time
|
|
47
|
+
* claims jose already verified, and build the principal (without session data).
|
|
48
|
+
*
|
|
49
|
+
* Users: `actorType` USER, a session, a client that is not the subject.
|
|
50
|
+
* Services: subject equals client ID, `actorType` SERVICE_ACCOUNT, `token_type` access, no session.
|
|
51
|
+
* Both: `jti`, string-array `permissions`, `exp - iat` within the lifetime limit.
|
|
52
|
+
*/
|
|
53
|
+
export function principalFromClaims(payload, policy) {
|
|
54
|
+
const { iat, exp, sub, jti, iss } = payload;
|
|
55
|
+
if (typeof iat !== 'number' || typeof exp !== 'number' || exp <= iat || exp - iat > policy.maxLifetimeSec) {
|
|
56
|
+
throw new OathweaveError('invalid_lifetime');
|
|
57
|
+
}
|
|
58
|
+
if (!nonEmpty(sub) || !nonEmpty(jti) || !nonEmpty(iss))
|
|
59
|
+
throw new OathweaveError('invalid_claims');
|
|
60
|
+
const permissions = payload['permissions'];
|
|
61
|
+
const clientIdClaim = policy.clientIdClaim ?? 'either';
|
|
62
|
+
const roles = extractRoles(payload, policy.requireRoles ?? false);
|
|
63
|
+
const clientId = clientIdOf(payload, clientIdClaim);
|
|
64
|
+
if (!stringArray(permissions) || roles === undefined || clientId === undefined)
|
|
65
|
+
throw new OathweaveError('invalid_claims');
|
|
66
|
+
const kind = principalKindOf(payload, clientIdClaim);
|
|
67
|
+
if (policy.accept !== 'any' && policy.accept !== kind)
|
|
68
|
+
throw new OathweaveError('wrong_principal_kind', { details: { kind } });
|
|
69
|
+
const base = {
|
|
70
|
+
subject: sub,
|
|
71
|
+
issuer: iss,
|
|
72
|
+
clientId,
|
|
73
|
+
tokenId: jti,
|
|
74
|
+
roles,
|
|
75
|
+
permissions: [...permissions],
|
|
76
|
+
scopes: extractScopes(payload),
|
|
77
|
+
issuedAt: iat,
|
|
78
|
+
expiresAt: exp,
|
|
79
|
+
claims: payload,
|
|
80
|
+
};
|
|
81
|
+
if (kind === 'service') {
|
|
82
|
+
if (payload['actorType'] !== 'SERVICE_ACCOUNT' || payload['token_type'] !== 'access' || payload['sessionId'] !== undefined) {
|
|
83
|
+
throw new OathweaveError('invalid_claims');
|
|
84
|
+
}
|
|
85
|
+
return { kind, ...base };
|
|
86
|
+
}
|
|
87
|
+
const sessionId = payload['sessionId'];
|
|
88
|
+
if (payload['actorType'] !== 'USER' || !nonEmpty(sessionId) || !nonEmpty(clientId))
|
|
89
|
+
throw new OathweaveError('invalid_claims');
|
|
90
|
+
const name = payload['name'] ?? payload['username'];
|
|
91
|
+
return {
|
|
92
|
+
kind,
|
|
93
|
+
...base,
|
|
94
|
+
sessionId,
|
|
95
|
+
displayName: nonEmpty(name) ? name : sub,
|
|
96
|
+
authTime: validAuthTime(payload['auth_time']),
|
|
97
|
+
sessionChecked: false,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -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;
|
|
@@ -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;
|