@oxy.so/core 1.12.1 → 1.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/crypto/webIdentityCarrier.js +1 -1
- package/dist/cjs/i18n/locales/ar-SA.json +33 -88
- package/dist/cjs/i18n/locales/ca-ES.json +35 -90
- package/dist/cjs/i18n/locales/de-DE.json +33 -88
- package/dist/cjs/i18n/locales/en-US.json +45 -141
- package/dist/cjs/i18n/locales/es-ES.json +44 -140
- package/dist/cjs/i18n/locales/fr-FR.json +33 -88
- package/dist/cjs/i18n/locales/it-IT.json +33 -88
- package/dist/cjs/i18n/locales/ja-JP.json +33 -88
- package/dist/cjs/i18n/locales/ko-KR.json +33 -88
- package/dist/cjs/i18n/locales/locales/ar-SA.json +33 -88
- package/dist/cjs/i18n/locales/locales/ca-ES.json +35 -90
- package/dist/cjs/i18n/locales/locales/de-DE.json +33 -88
- package/dist/cjs/i18n/locales/locales/en-US.json +45 -141
- package/dist/cjs/i18n/locales/locales/es-ES.json +44 -140
- package/dist/cjs/i18n/locales/locales/fr-FR.json +33 -88
- package/dist/cjs/i18n/locales/locales/it-IT.json +33 -88
- package/dist/cjs/i18n/locales/locales/ja-JP.json +33 -88
- package/dist/cjs/i18n/locales/locales/ko-KR.json +33 -88
- package/dist/cjs/i18n/locales/locales/pt-PT.json +33 -88
- package/dist/cjs/i18n/locales/locales/zh-CN.json +34 -89
- package/dist/cjs/i18n/locales/pt-PT.json +33 -88
- package/dist/cjs/i18n/locales/zh-CN.json +34 -89
- package/dist/cjs/index.js +10 -7
- package/dist/cjs/mixins/OxyServices.utility.js +140 -0
- package/dist/cjs/server/securityHeaders.js +19 -1
- package/dist/cjs/session/accountDialogController.js +37 -142
- package/dist/cjs/utils/authWebUrl.js +7 -6
- package/dist/cjs/utils/oauthPkce.js +5 -1
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/crypto/webIdentityCarrier.js +1 -1
- package/dist/esm/i18n/locales/ar-SA.json +33 -88
- package/dist/esm/i18n/locales/ca-ES.json +35 -90
- package/dist/esm/i18n/locales/de-DE.json +33 -88
- package/dist/esm/i18n/locales/en-US.json +45 -141
- package/dist/esm/i18n/locales/es-ES.json +44 -140
- package/dist/esm/i18n/locales/fr-FR.json +33 -88
- package/dist/esm/i18n/locales/it-IT.json +33 -88
- package/dist/esm/i18n/locales/ja-JP.json +33 -88
- package/dist/esm/i18n/locales/ko-KR.json +33 -88
- package/dist/esm/i18n/locales/locales/ar-SA.json +33 -88
- package/dist/esm/i18n/locales/locales/ca-ES.json +35 -90
- package/dist/esm/i18n/locales/locales/de-DE.json +33 -88
- package/dist/esm/i18n/locales/locales/en-US.json +45 -141
- package/dist/esm/i18n/locales/locales/es-ES.json +44 -140
- package/dist/esm/i18n/locales/locales/fr-FR.json +33 -88
- package/dist/esm/i18n/locales/locales/it-IT.json +33 -88
- package/dist/esm/i18n/locales/locales/ja-JP.json +33 -88
- package/dist/esm/i18n/locales/locales/ko-KR.json +33 -88
- package/dist/esm/i18n/locales/locales/pt-PT.json +33 -88
- package/dist/esm/i18n/locales/locales/zh-CN.json +34 -89
- package/dist/esm/i18n/locales/pt-PT.json +33 -88
- package/dist/esm/i18n/locales/zh-CN.json +34 -89
- package/dist/esm/index.js +2 -1
- package/dist/esm/mixins/OxyServices.utility.js +138 -0
- package/dist/esm/server/securityHeaders.js +19 -1
- package/dist/esm/session/accountDialogController.js +37 -142
- package/dist/esm/utils/authWebUrl.js +6 -5
- package/dist/esm/utils/oauthPkce.js +5 -1
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/crypto/webIdentityCarrier.d.ts +1 -1
- package/dist/types/index.d.ts +5 -3
- package/dist/types/mixins/OxyServices.utility.d.ts +84 -1
- package/dist/types/server/requesterAssertion.d.ts +2 -2
- package/dist/types/server/securityHeaders.d.ts +9 -12
- package/dist/types/session/accountDialogController.d.ts +29 -98
- package/dist/types/utils/authWebUrl.d.ts +6 -5
- package/dist/types/utils/oauthPkce.d.ts +10 -0
- package/package.json +2 -2
- package/src/crypto/webIdentityCarrier.ts +1 -1
- package/src/i18n/locales/ar-SA.json +33 -88
- package/src/i18n/locales/ca-ES.json +35 -90
- package/src/i18n/locales/de-DE.json +33 -88
- package/src/i18n/locales/en-US.json +45 -141
- package/src/i18n/locales/es-ES.json +44 -140
- package/src/i18n/locales/fr-FR.json +33 -88
- package/src/i18n/locales/it-IT.json +33 -88
- package/src/i18n/locales/ja-JP.json +33 -88
- package/src/i18n/locales/ko-KR.json +33 -88
- package/src/i18n/locales/pt-PT.json +33 -88
- package/src/i18n/locales/zh-CN.json +34 -89
- package/src/index.ts +9 -3
- package/src/mixins/OxyServices.utility.ts +208 -0
- package/src/mixins/__tests__/accountEvents.test.ts +158 -0
- package/src/server/__tests__/securityHeaders.test.ts +17 -0
- package/src/server/securityHeaders.ts +29 -1
- package/src/session/__tests__/accountDialogController.test.ts +42 -184
- package/src/session/accountDialogController.ts +50 -185
- package/src/utils/__tests__/oauthPkce.test.ts +7 -0
- package/src/utils/authWebUrl.ts +6 -5
- package/src/utils/oauthPkce.ts +16 -1
|
@@ -57,6 +57,73 @@ export interface ServiceActingAsVerification {
|
|
|
57
57
|
scopes: string[];
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/** The SET `events` member Oxy uses for an account deletion. */
|
|
61
|
+
export const OXY_ACCOUNT_DELETED_EVENT_URI = 'https://oxy.so/events/account.deleted';
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A verified account event from Oxy (OxyHQ/Mention#1169): a person deleted
|
|
65
|
+
* their Oxy account and every relying application must erase what it holds
|
|
66
|
+
* for them. Returned by `verifyAccountEvent` only after the signature, `typ`,
|
|
67
|
+
* issuer and audience all check out.
|
|
68
|
+
*/
|
|
69
|
+
export interface OxyAccountEvent {
|
|
70
|
+
/** The event id (`jti`). Stable across webhook retries and the pull feed: dedupe on it. */
|
|
71
|
+
eventId: string;
|
|
72
|
+
type: 'account.deleted';
|
|
73
|
+
/** The deleted Oxy account. */
|
|
74
|
+
userId: string;
|
|
75
|
+
/**
|
|
76
|
+
* The account's handle at deletion time, for relying parties that address
|
|
77
|
+
* the person by handle (an ActivityPub actor URI). `null` when the account had
|
|
78
|
+
* none, or when the token predates the field.
|
|
79
|
+
*/
|
|
80
|
+
username: string | null;
|
|
81
|
+
/** ISO-8601 time the deletion committed. */
|
|
82
|
+
occurredAt: string;
|
|
83
|
+
/** `true` when Oxy archived the row to keep financial records. Erase either way. */
|
|
84
|
+
retained: boolean;
|
|
85
|
+
/** The application the event was addressed to (`aud`). */
|
|
86
|
+
applicationId: string;
|
|
87
|
+
/** Seconds since the epoch the token was issued at (`iat`). */
|
|
88
|
+
issuedAt: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** One entry of the account-event pull feed (`GET /account-events`). */
|
|
92
|
+
export interface OxyAccountEventFeedItem {
|
|
93
|
+
eventId: string;
|
|
94
|
+
type: 'account.deleted';
|
|
95
|
+
userId: string;
|
|
96
|
+
username: string | null;
|
|
97
|
+
occurredAt: string;
|
|
98
|
+
retained: boolean;
|
|
99
|
+
/** The same signed token the webhook carries. Verify it with `verifyAccountEvent`. */
|
|
100
|
+
token: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface OxyAccountEventFeedPage {
|
|
104
|
+
events: OxyAccountEventFeedItem[];
|
|
105
|
+
/** Pass back as `after` to continue; unchanged when the page is empty. */
|
|
106
|
+
nextCursor: string | null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface VerifyAccountEventOptions {
|
|
110
|
+
/**
|
|
111
|
+
* The application id the token must be addressed to. Defaults to the `appId`
|
|
112
|
+
* of this client's configured service credential.
|
|
113
|
+
*/
|
|
114
|
+
audience?: string;
|
|
115
|
+
/** Defaults to `/.well-known/jwks.json` on this client's API origin. */
|
|
116
|
+
jwksUrl?: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Why an account event token was refused. `message` never contains the token. */
|
|
120
|
+
export class OxyAccountEventError extends Error {
|
|
121
|
+
constructor(message: string) {
|
|
122
|
+
super(message);
|
|
123
|
+
this.name = 'OxyAccountEventError';
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
60
127
|
/**
|
|
61
128
|
* Service app metadata attached to requests authenticated with service tokens.
|
|
62
129
|
*
|
|
@@ -334,6 +401,147 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
334
401
|
}
|
|
335
402
|
}
|
|
336
403
|
|
|
404
|
+
/**
|
|
405
|
+
* Verify an account event token from Oxy — the body of an account-event
|
|
406
|
+
* webhook, or an entry's `token` from `listAccountEvents` — and return the
|
|
407
|
+
* event. Throws {@link OxyAccountEventError} unless the token is an
|
|
408
|
+
* EdDSA-signed `secevent+jwt` from Oxy's published key set, issued by
|
|
409
|
+
* `oxy-auth`, addressed to this application, carrying exactly one known
|
|
410
|
+
* event. A service token (`typ: JWT`) is refused here, as this token is
|
|
411
|
+
* refused as a service token.
|
|
412
|
+
*
|
|
413
|
+
* Node only (it needs `node:crypto`). The caller must still be idempotent on
|
|
414
|
+
* `eventId`: Oxy delivers at least once, by push and by pull.
|
|
415
|
+
*/
|
|
416
|
+
async verifyAccountEvent(
|
|
417
|
+
token: string,
|
|
418
|
+
options: VerifyAccountEventOptions = {},
|
|
419
|
+
): Promise<OxyAccountEvent> {
|
|
420
|
+
if (typeof token !== 'string' || token.length === 0 || token.length > 16 * 1024) {
|
|
421
|
+
throw new OxyAccountEventError('Account event token is missing or oversized');
|
|
422
|
+
}
|
|
423
|
+
const parts = token.split('.');
|
|
424
|
+
if (parts.length !== 3 || parts.some((part) => part.length === 0)) {
|
|
425
|
+
throw new OxyAccountEventError('Account event token is not a compact JWS');
|
|
426
|
+
}
|
|
427
|
+
const [headerB64, payloadB64, signatureB64] = parts as [string, string, string];
|
|
428
|
+
let header: Record<string, unknown>;
|
|
429
|
+
let payload: Record<string, unknown>;
|
|
430
|
+
try {
|
|
431
|
+
header = parseJsonSegment(headerB64);
|
|
432
|
+
payload = parseJsonSegment(payloadB64);
|
|
433
|
+
} catch {
|
|
434
|
+
throw new OxyAccountEventError('Account event token is malformed');
|
|
435
|
+
}
|
|
436
|
+
if (
|
|
437
|
+
Object.keys(header).length !== 3
|
|
438
|
+
|| header.alg !== 'EdDSA'
|
|
439
|
+
|| header.typ !== 'secevent+jwt'
|
|
440
|
+
|| typeof header.kid !== 'string'
|
|
441
|
+
|| !/^[A-Za-z0-9._-]{1,128}$/.test(header.kid)
|
|
442
|
+
) {
|
|
443
|
+
throw new OxyAccountEventError('Account event token header is not supported');
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const nodeCrypto = await loadNodeCrypto();
|
|
447
|
+
let jwk: ServiceTokenPublicJwk;
|
|
448
|
+
try {
|
|
449
|
+
jwk = await resolveServiceTokenPublicKey(
|
|
450
|
+
header.kid,
|
|
451
|
+
options.jwksUrl ?? new URL('/.well-known/jwks.json', this.getBaseURL()).toString(),
|
|
452
|
+
this._serviceTokenJwksCache,
|
|
453
|
+
);
|
|
454
|
+
} catch (error) {
|
|
455
|
+
throw new OxyAccountEventError(
|
|
456
|
+
error instanceof Error ? error.message.replace('Service token', 'Account event') : 'Signing key is unavailable',
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
const signature = Buffer.from(signatureB64, 'base64url');
|
|
460
|
+
if (signature.toString('base64url') !== signatureB64 || signature.length !== 64) {
|
|
461
|
+
throw new OxyAccountEventError('Account event token signature is malformed');
|
|
462
|
+
}
|
|
463
|
+
let verified = false;
|
|
464
|
+
try {
|
|
465
|
+
const publicKey = nodeCrypto.createPublicKey({ key: jwk as unknown as JsonWebKey, format: 'jwk' });
|
|
466
|
+
verified = nodeCrypto.verify(null, Buffer.from(`${headerB64}.${payloadB64}`), publicKey, signature);
|
|
467
|
+
} catch {
|
|
468
|
+
verified = false;
|
|
469
|
+
}
|
|
470
|
+
if (!verified) throw new OxyAccountEventError('Account event token signature is invalid');
|
|
471
|
+
|
|
472
|
+
if (payload.iss !== OXY_JWT_ISSUER) {
|
|
473
|
+
throw new OxyAccountEventError('Account event token issuer is not Oxy');
|
|
474
|
+
}
|
|
475
|
+
const audience = options.audience ?? await this._configuredServiceAppId();
|
|
476
|
+
if (typeof payload.aud !== 'string' || payload.aud !== audience) {
|
|
477
|
+
throw new OxyAccountEventError('Account event token is addressed to another application');
|
|
478
|
+
}
|
|
479
|
+
if (typeof payload.jti !== 'string' || payload.jti.length === 0 || payload.jti.length > 128) {
|
|
480
|
+
throw new OxyAccountEventError('Account event token has no event id');
|
|
481
|
+
}
|
|
482
|
+
if (typeof payload.iat !== 'number' || !Number.isFinite(payload.iat)) {
|
|
483
|
+
throw new OxyAccountEventError('Account event token has no issue time');
|
|
484
|
+
}
|
|
485
|
+
const events = payload.events;
|
|
486
|
+
if (typeof events !== 'object' || events === null || Array.isArray(events)) {
|
|
487
|
+
throw new OxyAccountEventError('Account event token carries no events');
|
|
488
|
+
}
|
|
489
|
+
const entries = Object.entries(events as Record<string, unknown>);
|
|
490
|
+
const deleted = (events as Record<string, unknown>)[OXY_ACCOUNT_DELETED_EVENT_URI];
|
|
491
|
+
if (entries.length !== 1 || typeof deleted !== 'object' || deleted === null || Array.isArray(deleted)) {
|
|
492
|
+
throw new OxyAccountEventError('Account event token carries an unknown event');
|
|
493
|
+
}
|
|
494
|
+
const body = deleted as Record<string, unknown>;
|
|
495
|
+
if (typeof body.userId !== 'string' || body.userId.length === 0 || body.userId.length > 128) {
|
|
496
|
+
throw new OxyAccountEventError('Account event names no account');
|
|
497
|
+
}
|
|
498
|
+
if (typeof body.occurredAt !== 'string' || Number.isNaN(Date.parse(body.occurredAt))) {
|
|
499
|
+
throw new OxyAccountEventError('Account event has no occurrence time');
|
|
500
|
+
}
|
|
501
|
+
if (body.username !== undefined && body.username !== null && typeof body.username !== 'string') {
|
|
502
|
+
throw new OxyAccountEventError('Account event username is malformed');
|
|
503
|
+
}
|
|
504
|
+
return {
|
|
505
|
+
eventId: payload.jti,
|
|
506
|
+
type: 'account.deleted',
|
|
507
|
+
userId: body.userId,
|
|
508
|
+
username: typeof body.username === 'string' ? body.username : null,
|
|
509
|
+
occurredAt: body.occurredAt,
|
|
510
|
+
retained: body.retained === true,
|
|
511
|
+
applicationId: payload.aud,
|
|
512
|
+
issuedAt: payload.iat,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* One page of account events addressed to this application, oldest first,
|
|
518
|
+
* from Oxy's pull feed — the reconciliation path behind the webhook. Needs
|
|
519
|
+
* the service credential (`configureServiceAuth`). Verify each entry's
|
|
520
|
+
* `token` with {@link verifyAccountEvent} before acting on it.
|
|
521
|
+
*/
|
|
522
|
+
async listAccountEvents(
|
|
523
|
+
options: { after?: string; limit?: number } = {},
|
|
524
|
+
): Promise<OxyAccountEventFeedPage> {
|
|
525
|
+
const serviceToken = await (this as unknown as OxyAuthInstance).getServiceToken();
|
|
526
|
+
const query: Record<string, string> = {};
|
|
527
|
+
if (options.after) query.after = options.after;
|
|
528
|
+
if (options.limit !== undefined) query.limit = String(options.limit);
|
|
529
|
+
return this.makeRequest<OxyAccountEventFeedPage>('GET', '/account-events', query, {
|
|
530
|
+
cache: false,
|
|
531
|
+
headers: { Authorization: `Bearer ${serviceToken}` },
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** @internal The `appId` claim of this client's own service token. */
|
|
536
|
+
async _configuredServiceAppId(): Promise<string> {
|
|
537
|
+
const serviceToken = await (this as unknown as OxyAuthInstance).getServiceToken();
|
|
538
|
+
const appId = jwtDecode<JwtPayload>(serviceToken).appId;
|
|
539
|
+
if (typeof appId !== 'string' || appId.length === 0) {
|
|
540
|
+
throw new OxyAccountEventError('No audience given and the service credential names no application');
|
|
541
|
+
}
|
|
542
|
+
return appId;
|
|
543
|
+
}
|
|
544
|
+
|
|
337
545
|
/**
|
|
338
546
|
* Express.js authentication middleware
|
|
339
547
|
*
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { generateKeyPairSync, sign as signBytes, type KeyObject } from 'node:crypto';
|
|
2
|
+
import { OxyServices } from '../../OxyServices';
|
|
3
|
+
import { OXY_ACCOUNT_DELETED_EVENT_URI, OxyAccountEventError } from '../OxyServices.utility';
|
|
4
|
+
|
|
5
|
+
const b64url = (value: string | Uint8Array): string => Buffer.from(value).toString('base64url');
|
|
6
|
+
|
|
7
|
+
function sign(
|
|
8
|
+
privateKey: KeyObject,
|
|
9
|
+
header: Record<string, unknown>,
|
|
10
|
+
payload: Record<string, unknown>,
|
|
11
|
+
): string {
|
|
12
|
+
const signingInput = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`;
|
|
13
|
+
return `${signingInput}.${b64url(signBytes(null, Buffer.from(signingInput), privateKey))}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const KID = 'oxy-service-test';
|
|
17
|
+
|
|
18
|
+
function eventToken(
|
|
19
|
+
privateKey: KeyObject,
|
|
20
|
+
overrides: { header?: Record<string, unknown>; payload?: Record<string, unknown>; event?: Record<string, unknown> } = {},
|
|
21
|
+
): string {
|
|
22
|
+
return sign(
|
|
23
|
+
privateKey,
|
|
24
|
+
{ alg: 'EdDSA', typ: 'secevent+jwt', kid: KID, ...overrides.header },
|
|
25
|
+
{
|
|
26
|
+
iss: 'oxy-auth',
|
|
27
|
+
aud: 'app-mention',
|
|
28
|
+
iat: 1_790_000_000,
|
|
29
|
+
jti: '019a0000-0000-7000-8000-000000000001',
|
|
30
|
+
events: {
|
|
31
|
+
[OXY_ACCOUNT_DELETED_EVENT_URI]: {
|
|
32
|
+
userId: 'user-deleted',
|
|
33
|
+
username: 'qatest0925',
|
|
34
|
+
occurredAt: '2026-09-26T09:14:00.000Z',
|
|
35
|
+
retained: false,
|
|
36
|
+
...overrides.event,
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
...overrides.payload,
|
|
40
|
+
},
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('verifyAccountEvent', () => {
|
|
45
|
+
const oxyKey = generateKeyPairSync('ed25519');
|
|
46
|
+
const strangerKey = generateKeyPairSync('ed25519');
|
|
47
|
+
const jwk = { ...oxyKey.publicKey.export({ format: 'jwk' }), use: 'sig', alg: 'EdDSA', kid: KID };
|
|
48
|
+
|
|
49
|
+
let fetchSpy: jest.SpyInstance;
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
fetchSpy = jest.spyOn(globalThis, 'fetch').mockImplementation(async () => new Response(
|
|
52
|
+
JSON.stringify({ keys: [jwk] }),
|
|
53
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
54
|
+
));
|
|
55
|
+
});
|
|
56
|
+
afterEach(() => jest.restoreAllMocks());
|
|
57
|
+
|
|
58
|
+
const client = () => new OxyServices({ baseURL: 'https://api.oxy.test' });
|
|
59
|
+
|
|
60
|
+
it('returns the event from a genuine Oxy-signed token, verified against the public JWKS', async () => {
|
|
61
|
+
const event = await client().verifyAccountEvent(eventToken(oxyKey.privateKey), { audience: 'app-mention' });
|
|
62
|
+
|
|
63
|
+
expect(event).toEqual({
|
|
64
|
+
eventId: '019a0000-0000-7000-8000-000000000001',
|
|
65
|
+
type: 'account.deleted',
|
|
66
|
+
userId: 'user-deleted',
|
|
67
|
+
username: 'qatest0925',
|
|
68
|
+
occurredAt: '2026-09-26T09:14:00.000Z',
|
|
69
|
+
retained: false,
|
|
70
|
+
applicationId: 'app-mention',
|
|
71
|
+
issuedAt: 1_790_000_000,
|
|
72
|
+
});
|
|
73
|
+
expect(fetchSpy).toHaveBeenCalledWith(
|
|
74
|
+
'https://api.oxy.test/.well-known/jwks.json',
|
|
75
|
+
expect.objectContaining({ method: 'GET', redirect: 'error' }),
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('reads a token without `username` (issued before the field existed) as username null', async () => {
|
|
80
|
+
const token = eventToken(oxyKey.privateKey, { event: { username: undefined } });
|
|
81
|
+
const event = await client().verifyAccountEvent(token, { audience: 'app-mention' });
|
|
82
|
+
expect(event.username).toBeNull();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('carries `retained` through for an archived account', async () => {
|
|
86
|
+
const token = eventToken(oxyKey.privateKey, { event: { retained: true } });
|
|
87
|
+
await expect(client().verifyAccountEvent(token, { audience: 'app-mention' }))
|
|
88
|
+
.resolves.toMatchObject({ retained: true });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('defaults the audience to the appId of the configured service credential', async () => {
|
|
92
|
+
const oxy = client();
|
|
93
|
+
const serviceToken = `${b64url(JSON.stringify({ alg: 'EdDSA' }))}.${b64url(JSON.stringify({ appId: 'app-mention' }))}.sig`;
|
|
94
|
+
jest.spyOn(oxy, 'getServiceToken').mockResolvedValue(serviceToken);
|
|
95
|
+
|
|
96
|
+
await expect(oxy.verifyAccountEvent(eventToken(oxyKey.privateKey))).resolves.toMatchObject({
|
|
97
|
+
applicationId: 'app-mention',
|
|
98
|
+
});
|
|
99
|
+
await expect(oxy.verifyAccountEvent(eventToken(oxyKey.privateKey, { payload: { aud: 'app-other' } })))
|
|
100
|
+
.rejects.toThrow('addressed to another application');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const refusals: Array<[string, () => string, string]> = [
|
|
104
|
+
['a token signed by a key Oxy did not publish', () => eventToken(strangerKey.privateKey), 'signature is invalid'],
|
|
105
|
+
['a service token (typ JWT) replayed as an event', () => eventToken(oxyKey.privateKey, { header: { typ: 'JWT' } }), 'header is not supported'],
|
|
106
|
+
['a token for another application', () => eventToken(oxyKey.privateKey, { payload: { aud: 'app-other' } }), 'addressed to another application'],
|
|
107
|
+
['a token from another issuer', () => eventToken(oxyKey.privateKey, { payload: { iss: 'someone-else' } }), 'issuer is not Oxy'],
|
|
108
|
+
['a token without an event id', () => eventToken(oxyKey.privateKey, { payload: { jti: undefined } }), 'no event id'],
|
|
109
|
+
['a token with an unknown event', () => eventToken(oxyKey.privateKey, { payload: { events: { 'https://oxy.so/events/other': {} } } }), 'unknown event'],
|
|
110
|
+
['a token naming no account', () => eventToken(oxyKey.privateKey, { event: { userId: '' } }), 'names no account'],
|
|
111
|
+
['a token whose username is not a string', () => eventToken(oxyKey.privateKey, { event: { username: 42 } }), 'username is malformed'],
|
|
112
|
+
['a token with an unknown key id', () => eventToken(oxyKey.privateKey, { header: { kid: 'rotated-away' } }), 'unknown'],
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
it.each(refusals)('refuses %s', async (_label, build, message) => {
|
|
116
|
+
const attempt = client().verifyAccountEvent(build(), { audience: 'app-mention' });
|
|
117
|
+
await expect(attempt).rejects.toBeInstanceOf(OxyAccountEventError);
|
|
118
|
+
await expect(client().verifyAccountEvent(build(), { audience: 'app-mention' })).rejects.toThrow(message);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('refuses a tampered payload', async () => {
|
|
122
|
+
const [header, , signature] = eventToken(oxyKey.privateKey).split('.');
|
|
123
|
+
const forged = b64url(JSON.stringify({
|
|
124
|
+
iss: 'oxy-auth',
|
|
125
|
+
aud: 'app-mention',
|
|
126
|
+
iat: 1,
|
|
127
|
+
jti: 'x',
|
|
128
|
+
events: { [OXY_ACCOUNT_DELETED_EVENT_URI]: { userId: 'someone-else', occurredAt: '2026-09-26T00:00:00Z' } },
|
|
129
|
+
}));
|
|
130
|
+
await expect(client().verifyAccountEvent(`${header}.${forged}.${signature}`, { audience: 'app-mention' }))
|
|
131
|
+
.rejects.toThrow('signature is invalid');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('refuses garbage without fetching keys', async () => {
|
|
135
|
+
await expect(client().verifyAccountEvent('not-a-token', { audience: 'app-mention' }))
|
|
136
|
+
.rejects.toThrow('not a compact JWS');
|
|
137
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('listAccountEvents', () => {
|
|
142
|
+
afterEach(() => jest.restoreAllMocks());
|
|
143
|
+
|
|
144
|
+
it('reads the pull feed with the service token and passes the cursor', async () => {
|
|
145
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.test' });
|
|
146
|
+
jest.spyOn(oxy, 'getServiceToken').mockResolvedValue('service-token');
|
|
147
|
+
const page = { events: [], nextCursor: 'cursor-1' };
|
|
148
|
+
const request = jest.spyOn(oxy, 'makeRequest').mockResolvedValue(page as never);
|
|
149
|
+
|
|
150
|
+
await expect(oxy.listAccountEvents({ after: 'cursor-1', limit: 50 })).resolves.toEqual(page);
|
|
151
|
+
expect(request).toHaveBeenCalledWith(
|
|
152
|
+
'GET',
|
|
153
|
+
'/account-events',
|
|
154
|
+
{ after: 'cursor-1', limit: '50' },
|
|
155
|
+
expect.objectContaining({ cache: false, headers: { Authorization: 'Bearer service-token' } }),
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -218,6 +218,23 @@ describe('@oxy.so/core/server buildOxyPagesHeaders on a sensitive origin (ADR 00
|
|
|
218
218
|
);
|
|
219
219
|
expect(buildOxyPagesHeaders()).not.toContain('Cache-Control');
|
|
220
220
|
});
|
|
221
|
+
|
|
222
|
+
it('serves the rest of the holder policy there, and only there (ADR 0028)', () => {
|
|
223
|
+
const sensitive = buildOxyPagesHeaders({ sensitive: true });
|
|
224
|
+
const normal = buildOxyPagesHeaders();
|
|
225
|
+
|
|
226
|
+
expect(sensitive).toContain("base-uri 'none'");
|
|
227
|
+
expect(sensitive).toContain("form-action 'none'");
|
|
228
|
+
expect(sensitive).toContain('Referrer-Policy: no-referrer');
|
|
229
|
+
expect(sensitive).toContain('Permissions-Policy: publickey-credentials-get=(self), publickey-credentials-create=(self)');
|
|
230
|
+
expect(sensitive).toContain('Cross-Origin-Resource-Policy: same-origin');
|
|
231
|
+
// The third-party OAuth popup reports back to its opener: COOP would sever it.
|
|
232
|
+
expect(sensitive).not.toContain('Cross-Origin-Opener-Policy');
|
|
233
|
+
|
|
234
|
+
expect(normal).toContain('Referrer-Policy: strict-origin-when-cross-origin');
|
|
235
|
+
expect(normal).not.toContain('Permissions-Policy');
|
|
236
|
+
expect(normal).not.toContain("base-uri 'none'");
|
|
237
|
+
});
|
|
221
238
|
});
|
|
222
239
|
|
|
223
240
|
describe('@oxy.so/core/server extractInlineScripts', () => {
|
|
@@ -371,6 +371,15 @@ export interface OxyPagesHeadersOptions {
|
|
|
371
371
|
* injecting the beacon at all — otherwise every page load logs a CSP
|
|
372
372
|
* violation for a script we never asked for. The rest of the value is Pages'
|
|
373
373
|
* own default, so caching is unchanged.
|
|
374
|
+
*
|
|
375
|
+
* And, because this origin is the web identity carrier — where a person's
|
|
376
|
+
* self-custody root is created, opened, recovered and moved (ADR 0028) — the
|
|
377
|
+
* rest of the holder policy: no `<base>` and no form posts (`base-uri` /
|
|
378
|
+
* `form-action 'none'`), no Referer to anyone, passkeys for this origin only
|
|
379
|
+
* with every other powerful feature off (`Permissions-Policy`), and its
|
|
380
|
+
* responses readable by no other origin (`Cross-Origin-Resource-Policy`).
|
|
381
|
+
* Never `Cross-Origin-Opener-Policy`: the third-party OAuth popup reports
|
|
382
|
+
* its code to the app that opened it, and COOP severs `window.opener`.
|
|
374
383
|
*/
|
|
375
384
|
sensitive?: boolean;
|
|
376
385
|
/**
|
|
@@ -402,6 +411,21 @@ export interface OxyPagesHeadersOptions {
|
|
|
402
411
|
* react-native-web's runtime stylesheet, and a style hash would silently switch
|
|
403
412
|
* that off and render every Oxy web app unstyled.)
|
|
404
413
|
*/
|
|
414
|
+
/** Passkeys for this origin itself; every other powerful feature off. */
|
|
415
|
+
const SENSITIVE_PERMISSIONS_POLICY = [
|
|
416
|
+
'publickey-credentials-get=(self)',
|
|
417
|
+
'publickey-credentials-create=(self)',
|
|
418
|
+
'camera=()',
|
|
419
|
+
'microphone=()',
|
|
420
|
+
'geolocation=()',
|
|
421
|
+
'payment=()',
|
|
422
|
+
'usb=()',
|
|
423
|
+
'serial=()',
|
|
424
|
+
'hid=()',
|
|
425
|
+
'bluetooth=()',
|
|
426
|
+
'display-capture=()',
|
|
427
|
+
].join(', ');
|
|
428
|
+
|
|
405
429
|
export function buildOxyPagesHeaders(options: OxyPagesHeadersOptions = {}): string {
|
|
406
430
|
const hashes = [
|
|
407
431
|
...new Set((options.html ?? []).flatMap(extractInlineScripts).map(inlineScriptCspHash)),
|
|
@@ -422,6 +446,8 @@ export function buildOxyPagesHeaders(options: OxyPagesHeadersOptions = {}): stri
|
|
|
422
446
|
for (const name of ['script-src', 'connect-src']) {
|
|
423
447
|
directives[name] = (directives[name] ?? []).filter((source) => !beacon.has(source));
|
|
424
448
|
}
|
|
449
|
+
directives['base-uri'] = ["'none'"];
|
|
450
|
+
directives['form-action'] = ["'none'"];
|
|
425
451
|
}
|
|
426
452
|
const csp = formatOxyCspPolicy(directives);
|
|
427
453
|
const lines = [
|
|
@@ -429,13 +455,15 @@ export function buildOxyPagesHeaders(options: OxyPagesHeadersOptions = {}): stri
|
|
|
429
455
|
` Content-Security-Policy: ${csp}`,
|
|
430
456
|
' X-Frame-Options: DENY',
|
|
431
457
|
' X-Content-Type-Options: nosniff',
|
|
432
|
-
|
|
458
|
+
` Referrer-Policy: ${options.sensitive ? 'no-referrer' : 'strict-origin-when-cross-origin'}`,
|
|
433
459
|
];
|
|
434
460
|
if (options.hsts !== false) {
|
|
435
461
|
lines.push(' Strict-Transport-Security: max-age=31536000; includeSubDomains; preload');
|
|
436
462
|
}
|
|
437
463
|
if (options.sensitive) {
|
|
438
464
|
lines.push(' Cache-Control: public, max-age=0, must-revalidate, no-transform');
|
|
465
|
+
lines.push(` Permissions-Policy: ${SENSITIVE_PERMISSIONS_POLICY}`);
|
|
466
|
+
lines.push(' Cross-Origin-Resource-Policy: same-origin');
|
|
439
467
|
}
|
|
440
468
|
lines.push('');
|
|
441
469
|
return lines.join('\n');
|