@kubun/store-delegation 0.17.0 → 0.18.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/lib/api.d.ts +10 -0
- package/lib/api.js +83 -53
- package/lib/definition.js +3 -1
- package/lib/index.d.ts +1 -1
- package/lib/index.js +1 -1
- package/lib/migrations.js +18 -5
- package/lib/revocation-checker.d.ts +12 -2
- package/lib/revocation-checker.js +88 -28
- package/lib/tables.d.ts +3 -1
- package/package.json +10 -10
package/lib/api.d.ts
CHANGED
|
@@ -13,6 +13,16 @@ export type DelegationStoreAPI = {
|
|
|
13
13
|
atTime: number;
|
|
14
14
|
}): Promise<Array<DelegationToken>>;
|
|
15
15
|
listIssuedTokens(grantor: string): Promise<Array<DelegationToken>>;
|
|
16
|
+
/**
|
|
17
|
+
* Superseded grants for one key that are still unexpired at `atTime` and carry no
|
|
18
|
+
* revocation from their grantor: replaced capabilities that remain valid if presented.
|
|
19
|
+
*/
|
|
20
|
+
listUnrevokedSupersededTokens(params: {
|
|
21
|
+
grantor: string;
|
|
22
|
+
audience: string;
|
|
23
|
+
resource: string;
|
|
24
|
+
atTime: number;
|
|
25
|
+
}): Promise<Array<DelegationToken>>;
|
|
16
26
|
getDelegationTokenByGrantorJTI(params: {
|
|
17
27
|
jti: string;
|
|
18
28
|
grantor: string;
|
package/lib/api.js
CHANGED
|
@@ -46,63 +46,89 @@ const REVOCATION_GC_SAMPLE_RATE = 0.01;
|
|
|
46
46
|
// is folded through `normalizeDID`, because a raw equality here silently
|
|
47
47
|
// returns "not revoked".
|
|
48
48
|
export function createDelegationStore(db, adapter, logger = getKubunLogger('store-delegation')) {
|
|
49
|
+
// Shared transaction locks do not serialise concurrent calls on their own connection.
|
|
50
|
+
let ambientWriteChain = Promise.resolve();
|
|
49
51
|
const api = {
|
|
50
52
|
// --- Delegation token operations ---
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
// stays within the pad width; that ceiling is unreachable and the SQL
|
|
54
|
-
// compare avoids a read-modify-write race. The pre-read drives the returned
|
|
55
|
-
// "changed" flag (used by emission gates to suppress `delegationTokenAdded`
|
|
56
|
-
// on idempotent re-stores). It is not wrapped in a transaction because
|
|
57
|
-
// callers already run inside one — Kysely rejects nested transactions on
|
|
58
|
-
// shared connections. The narrow TOCTOU window between read and upsert can
|
|
59
|
-
// let a concurrent identical re-store race and emit twice; that is benign
|
|
60
|
-
// for emission gating, and the LWW upsert itself remains correct under the
|
|
61
|
-
// SQL `>` arbiter regardless.
|
|
53
|
+
// HLC text order matches HLC.compare while its counter stays within the pad width.
|
|
54
|
+
// Lock before reading so arbitration and the history-preserving replacement are atomic.
|
|
62
55
|
async addDelegationToken (token) {
|
|
63
56
|
const grantor = normalizeDID(token.grantor);
|
|
64
57
|
const audience = normalizeDID(token.audience);
|
|
65
|
-
const
|
|
66
|
-
'jti',
|
|
67
|
-
'token',
|
|
68
|
-
'act',
|
|
69
|
-
'exp',
|
|
70
|
-
'hlc'
|
|
71
|
-
]).where('grantor', '=', grantor).where('audience', '=', audience).where('resource', '=', token.resource).executeTakeFirst();
|
|
72
|
-
await db.insertInto('kubun_delegation_tokens').values({
|
|
73
|
-
jti: token.jti,
|
|
58
|
+
const key = {
|
|
74
59
|
grantor,
|
|
75
60
|
audience,
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
61
|
+
resource: token.resource
|
|
62
|
+
};
|
|
63
|
+
const replace = async (tx)=>{
|
|
64
|
+
// An upsert locks present and absent keys on Postgres, and reserves SQLite's writer.
|
|
65
|
+
await tx.insertInto('kubun_delegation_token_keys').values(key).onConflict((oc)=>oc.columns([
|
|
66
|
+
'grantor',
|
|
67
|
+
'audience',
|
|
68
|
+
'resource'
|
|
69
|
+
]).doUpdateSet({
|
|
70
|
+
resource: token.resource
|
|
71
|
+
})).execute();
|
|
72
|
+
const existing = await tx.selectFrom('kubun_delegation_tokens').select([
|
|
73
|
+
'jti',
|
|
74
|
+
'token',
|
|
75
|
+
'act',
|
|
76
|
+
'exp',
|
|
77
|
+
'hlc'
|
|
78
|
+
]).where('grantor', '=', grantor).where('audience', '=', audience).where('resource', '=', token.resource).where('superseded', '=', 0).executeTakeFirst();
|
|
79
|
+
if (existing != null && token.hlc <= existing.hlc) {
|
|
80
|
+
// A losing JTI may already have been delivered and must remain revocable.
|
|
81
|
+
await tx.insertInto('kubun_delegation_tokens').values({
|
|
82
|
+
...key,
|
|
83
|
+
jti: token.jti,
|
|
84
|
+
token: token.token,
|
|
85
|
+
act: token.act,
|
|
86
|
+
exp: token.exp,
|
|
87
|
+
hlc: token.hlc,
|
|
88
|
+
superseded: 1
|
|
89
|
+
}).onConflict((oc)=>oc.columns([
|
|
90
|
+
'grantor',
|
|
91
|
+
'audience',
|
|
92
|
+
'resource',
|
|
93
|
+
'jti'
|
|
94
|
+
]).doNothing()).execute();
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
if (existing != null && existing.jti !== token.jti) {
|
|
98
|
+
await tx.updateTable('kubun_delegation_tokens').set({
|
|
99
|
+
superseded: 1,
|
|
91
100
|
updated_at: adapter.encodeTimestamp(new Date())
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
101
|
+
}).where('grantor', '=', grantor).where('audience', '=', audience).where('resource', '=', token.resource).where('superseded', '=', 0).execute();
|
|
102
|
+
}
|
|
103
|
+
await tx.insertInto('kubun_delegation_tokens').values({
|
|
104
|
+
...key,
|
|
105
|
+
jti: token.jti,
|
|
106
|
+
token: token.token,
|
|
107
|
+
act: token.act,
|
|
108
|
+
exp: token.exp,
|
|
109
|
+
hlc: token.hlc,
|
|
110
|
+
superseded: 0
|
|
111
|
+
}).onConflict((oc)=>oc.columns([
|
|
112
|
+
'grantor',
|
|
113
|
+
'audience',
|
|
114
|
+
'resource',
|
|
115
|
+
'jti'
|
|
116
|
+
]).doUpdateSet((eb)=>({
|
|
117
|
+
token: eb.ref('excluded.token'),
|
|
118
|
+
act: eb.ref('excluded.act'),
|
|
119
|
+
exp: eb.ref('excluded.exp'),
|
|
120
|
+
hlc: eb.ref('excluded.hlc'),
|
|
121
|
+
superseded: 0,
|
|
122
|
+
updated_at: adapter.encodeTimestamp(new Date())
|
|
123
|
+
}))).execute();
|
|
124
|
+
return existing == null || existing.jti !== token.jti || existing.token !== token.token || existing.act !== token.act || existing.exp !== token.exp;
|
|
125
|
+
};
|
|
126
|
+
// Use an ambient transaction inline: Kysely rejects nested transactions.
|
|
127
|
+
const write = db.isTransaction ? ambientWriteChain.then(()=>replace(db)) : db.transaction().execute(replace);
|
|
128
|
+
if (db.isTransaction) {
|
|
129
|
+
ambientWriteChain = write.then(()=>undefined, ()=>undefined);
|
|
105
130
|
}
|
|
131
|
+
const changed = await write;
|
|
106
132
|
// Amortize revocation GC across delegation-token inserts. Sample rate
|
|
107
133
|
// keeps the sweep cost negligible while ensuring the table doesn't grow
|
|
108
134
|
// unbounded when the cap stream is busy. Best-effort — sweep failures
|
|
@@ -126,7 +152,7 @@ export function createDelegationStore(db, adapter, logger = getKubunLogger('stor
|
|
|
126
152
|
return changed;
|
|
127
153
|
},
|
|
128
154
|
async getDelegationTokens (params) {
|
|
129
|
-
return await db.selectFrom('kubun_delegation_tokens').selectAll().where('grantor', '=', normalizeDID(params.grantor)).where('audience', '=', normalizeDID(params.audience)).execute();
|
|
155
|
+
return await db.selectFrom('kubun_delegation_tokens').selectAll().where('grantor', '=', normalizeDID(params.grantor)).where('audience', '=', normalizeDID(params.audience)).where('superseded', '=', 0).execute();
|
|
130
156
|
},
|
|
131
157
|
// The join carries the author predicate for two reasons. It is an
|
|
132
158
|
// authorization filter — only the grantor's own claim may withhold a token
|
|
@@ -137,7 +163,7 @@ export function createDelegationStore(db, adapter, logger = getKubunLogger('stor
|
|
|
137
163
|
// the list as a delegation chain, and a duplicate breaks a multi-hop chain
|
|
138
164
|
// outright.
|
|
139
165
|
async getHeldTokens (params) {
|
|
140
|
-
return await db.selectFrom('kubun_delegation_tokens').leftJoin('kubun_revoked_capabilities', (join)=>join.onRef('kubun_revoked_capabilities.jti', '=', 'kubun_delegation_tokens.jti').onRef('kubun_revoked_capabilities.revoker_did', '=', 'kubun_delegation_tokens.grantor')).selectAll('kubun_delegation_tokens').where('audience', '=', normalizeDID(params.audience)).where('exp', '>', params.atTime)// Pending revocations (verified_at IS NULL) are not binding until the
|
|
166
|
+
return await db.selectFrom('kubun_delegation_tokens').leftJoin('kubun_revoked_capabilities', (join)=>join.onRef('kubun_revoked_capabilities.jti', '=', 'kubun_delegation_tokens.jti').onRef('kubun_revoked_capabilities.revoker_did', '=', 'kubun_delegation_tokens.grantor')).selectAll('kubun_delegation_tokens').where('audience', '=', normalizeDID(params.audience)).where('exp', '>', params.atTime).where('kubun_delegation_tokens.superseded', '=', 0)// Pending revocations (verified_at IS NULL) are not binding until the
|
|
141
167
|
// cap-arrival cross-check verifies them, so they don't filter tokens.
|
|
142
168
|
.where((eb)=>eb.or([
|
|
143
169
|
eb('kubun_revoked_capabilities.jti', 'is', null),
|
|
@@ -145,12 +171,16 @@ export function createDelegationStore(db, adapter, logger = getKubunLogger('stor
|
|
|
145
171
|
])).execute();
|
|
146
172
|
},
|
|
147
173
|
async listIssuedTokens (grantor) {
|
|
148
|
-
return await db.selectFrom('kubun_delegation_tokens').selectAll().where('grantor', '=', normalizeDID(grantor)).execute();
|
|
174
|
+
return await db.selectFrom('kubun_delegation_tokens').selectAll().where('grantor', '=', normalizeDID(grantor)).where('superseded', '=', 0).execute();
|
|
175
|
+
},
|
|
176
|
+
async listUnrevokedSupersededTokens (params) {
|
|
177
|
+
const grantor = normalizeDID(params.grantor);
|
|
178
|
+
return await db.selectFrom('kubun_delegation_tokens').leftJoin('kubun_revoked_capabilities', (join)=>join.onRef('kubun_revoked_capabilities.jti', '=', 'kubun_delegation_tokens.jti').onRef('kubun_revoked_capabilities.revoker_did', '=', 'kubun_delegation_tokens.grantor')).selectAll('kubun_delegation_tokens').where('kubun_delegation_tokens.grantor', '=', grantor).where('audience', '=', normalizeDID(params.audience)).where('resource', '=', params.resource).where('kubun_delegation_tokens.superseded', '=', 1).where('exp', '>', params.atTime).where('kubun_revoked_capabilities.jti', 'is', null).orderBy('kubun_delegation_tokens.hlc').orderBy('kubun_delegation_tokens.jti').execute();
|
|
149
179
|
},
|
|
150
180
|
// `jti` is picked by whoever minted the capability and the grant travels to
|
|
151
181
|
// the whole group, so any co-member can mint one reusing a `jti` it saw. It
|
|
152
182
|
// names a row only inside a single grantor's namespace, and even there it
|
|
153
|
-
// is not a key — the primary key is `(grantor, audience, resource)`. A
|
|
183
|
+
// is not a key — the primary key is `(grantor, audience, resource, jti)`. A
|
|
154
184
|
// grantor that reused one `jti` across two of its own grants would leave
|
|
155
185
|
// this read with no representable answer, so it throws rather than pick.
|
|
156
186
|
async getDelegationTokenByGrantorJTI (params) {
|
|
@@ -178,7 +208,7 @@ export function createDelegationStore(db, adapter, logger = getKubunLogger('stor
|
|
|
178
208
|
return (result.numDeletedRows ?? 0n) > 0n;
|
|
179
209
|
},
|
|
180
210
|
// --- Revoked capability operations ---
|
|
181
|
-
//
|
|
211
|
+
// LWW arbitration uses SQL string `>` on the
|
|
182
212
|
// serialized HLC is a stand-in for `HLC.compare`, valid only while the
|
|
183
213
|
// per-millisecond counter stays within the pad width. The pre-read drives
|
|
184
214
|
// the boolean change signal that emission gates consume; the upsert is
|
package/lib/definition.js
CHANGED
|
@@ -3,5 +3,7 @@ import { getDelegationMigrations } from './migrations.js';
|
|
|
3
3
|
export const delegationStoreDefinition = {
|
|
4
4
|
name: 'delegation',
|
|
5
5
|
migrations: getDelegationMigrations,
|
|
6
|
-
|
|
6
|
+
// Only the db and adapter: KubunDB passes a store context third, and this
|
|
7
|
+
// factory's third parameter means something else.
|
|
8
|
+
createAPI: (db, adapter)=>createDelegationStore(db, adapter)
|
|
7
9
|
};
|
package/lib/index.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { DelegationStoreAPI } from './api.js';
|
|
|
3
3
|
export type { DelegationStoreAPI } from './api.js';
|
|
4
4
|
export { createDelegationStore, MAX_CAP_TTL_SECONDS, MAX_REVOCATION_FUTURE_DRIFT_SECONDS, REVOCATION_GC_VERIFIED_GRACE_SECONDS, } from './api.js';
|
|
5
5
|
export { delegationStoreDefinition } from './definition.js';
|
|
6
|
-
export { createDelegationRevocationChecker, type RevocationClaims, } from './revocation-checker.js';
|
|
6
|
+
export { createDelegationRevocationChecker, type RevocationClaims, VerifiedRevocationError, } from './revocation-checker.js';
|
|
7
7
|
export declare const DELEGATION_STORE: "delegation";
|
|
8
8
|
export declare function getDelegationStore(provider: StoreProvider): Promise<DelegationStoreAPI>;
|
|
9
9
|
export type { DelegationStoreTables, DelegationToken, DelegationTokenTable, HeldRevocation, InsertDelegationToken, InsertRevokedCapability, RevokedCapability, RevokedCapabilityTable, } from './tables.js';
|
package/lib/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { createDelegationStore, MAX_CAP_TTL_SECONDS, MAX_REVOCATION_FUTURE_DRIFT_SECONDS, REVOCATION_GC_VERIFIED_GRACE_SECONDS } from './api.js';
|
|
2
2
|
export { delegationStoreDefinition } from './definition.js';
|
|
3
|
-
export { createDelegationRevocationChecker } from './revocation-checker.js';
|
|
3
|
+
export { createDelegationRevocationChecker, VerifiedRevocationError } from './revocation-checker.js';
|
|
4
4
|
export const DELEGATION_STORE = 'delegation';
|
|
5
5
|
export function getDelegationStore(provider) {
|
|
6
6
|
return provider.getStore(DELEGATION_STORE);
|
package/lib/migrations.js
CHANGED
|
@@ -1,18 +1,32 @@
|
|
|
1
|
+
import { sql } from 'kysely';
|
|
1
2
|
export function getDelegationMigrations(ctx) {
|
|
2
3
|
const t = ctx.types;
|
|
3
4
|
const now = ctx.functions.now;
|
|
4
5
|
const init = {
|
|
5
6
|
async up (db) {
|
|
7
|
+
// A stable row serialises replacements even before the first grant exists.
|
|
8
|
+
await db.schema.createTable('kubun_delegation_token_keys').ifNotExists().addColumn('grantor', t.text, (col)=>col.notNull()).addColumn('audience', t.text, (col)=>col.notNull()).addColumn('resource', t.text, (col)=>col.notNull()).addPrimaryKeyConstraint('pk_delegation_token_keys', [
|
|
9
|
+
'grantor',
|
|
10
|
+
'audience',
|
|
11
|
+
'resource'
|
|
12
|
+
]).execute();
|
|
6
13
|
// Delegation tokens
|
|
7
14
|
await db.schema.createTable('kubun_delegation_tokens').ifNotExists().addColumn('jti', t.text, (col)=>col.notNull()).addColumn('grantor', t.text, (col)=>col.notNull()).addColumn('audience', t.text, (col)=>col.notNull()).addColumn('token', t.text, (col)=>col.notNull()).addColumn('resource', t.text, (col)=>col.notNull()).addColumn('act', t.text, (col)=>col.notNull())// Capability expiry, a numeric unix-seconds claim mirrored from the
|
|
8
15
|
// token for indexed validity filtering. bigint, not int32: a 32-bit
|
|
9
16
|
// seconds column ceilings at 2038. The adapter's int8 parser reads it
|
|
10
17
|
// back as a JS number, so the row type stays `number`.
|
|
11
|
-
.addColumn('exp', t.bigint, (col)=>col.notNull()).addColumn('hlc', t.text, (col)=>col.notNull())
|
|
18
|
+
.addColumn('exp', t.bigint, (col)=>col.notNull()).addColumn('hlc', t.text, (col)=>col.notNull())// Integer 0/1 reads uniformly across SQLite and Postgres.
|
|
19
|
+
.addColumn('superseded', 'integer', (col)=>col.defaultTo(0).notNull()).addColumn('created_at', t.timestamp, (col)=>col.defaultTo(now).notNull()).addColumn('updated_at', t.timestamp).addPrimaryKeyConstraint('pk_delegation_tokens', [
|
|
12
20
|
'grantor',
|
|
13
21
|
'audience',
|
|
14
|
-
'resource'
|
|
22
|
+
'resource',
|
|
23
|
+
'jti'
|
|
15
24
|
]).execute();
|
|
25
|
+
await db.schema.createIndex('idx_delegation_tokens_active_key').ifNotExists().unique().on('kubun_delegation_tokens').columns([
|
|
26
|
+
'grantor',
|
|
27
|
+
'audience',
|
|
28
|
+
'resource'
|
|
29
|
+
]).where(sql.ref('superseded'), '=', 0).execute();
|
|
16
30
|
await db.schema.createIndex('idx_delegation_tokens_grantor_audience').ifNotExists().on('kubun_delegation_tokens').columns([
|
|
17
31
|
'grantor',
|
|
18
32
|
'audience'
|
|
@@ -23,9 +37,7 @@ export function getDelegationMigrations(ctx) {
|
|
|
23
37
|
'audience',
|
|
24
38
|
'exp'
|
|
25
39
|
]).execute();
|
|
26
|
-
//
|
|
27
|
-
// `(grantor, audience, resource)`. Index it so cap-by-jti lookups during
|
|
28
|
-
// revocation are not a table scan.
|
|
40
|
+
// JTI is last in the composite key; index issuer-history lookups separately.
|
|
29
41
|
await db.schema.createIndex('idx_delegation_tokens_jti').ifNotExists().on('kubun_delegation_tokens').column('jti').execute();
|
|
30
42
|
// Revoked capabilities. `verified_at` doubles as both the verified flag
|
|
31
43
|
// (NULL = pending cross-check vs the cap's iss) and a forensic
|
|
@@ -57,6 +69,7 @@ export function getDelegationMigrations(ctx) {
|
|
|
57
69
|
async down (db) {
|
|
58
70
|
await db.schema.dropTable('kubun_revoked_capabilities').ifExists().execute();
|
|
59
71
|
await db.schema.dropTable('kubun_delegation_tokens').ifExists().execute();
|
|
72
|
+
await db.schema.dropTable('kubun_delegation_token_keys').ifExists().execute();
|
|
60
73
|
}
|
|
61
74
|
};
|
|
62
75
|
return {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type VerifyTokenHook } from '@kokuin/capability';
|
|
1
|
+
import { type CapabilityToken, type VerifyTokenHook } from '@kokuin/capability';
|
|
2
2
|
import { type MethodRegistry } from '@kokuin/token';
|
|
3
3
|
import type { DelegationStoreAPI } from './api.js';
|
|
4
4
|
export type RevocationClaims = {
|
|
@@ -7,6 +7,11 @@ export type RevocationClaims = {
|
|
|
7
7
|
rev: true;
|
|
8
8
|
iat: number;
|
|
9
9
|
};
|
|
10
|
+
/** A revocation proved by kokuin after the store and resolver returned successfully. */
|
|
11
|
+
export declare class VerifiedRevocationError extends Error {
|
|
12
|
+
get brand(): string;
|
|
13
|
+
constructor(message: string, options?: ErrorOptions);
|
|
14
|
+
}
|
|
10
15
|
/**
|
|
11
16
|
* Revocation hook backed by the delegation store.
|
|
12
17
|
*
|
|
@@ -24,4 +29,9 @@ export type RevocationClaims = {
|
|
|
24
29
|
* without the registry it fails to resolve, is dropped, and the revoked
|
|
25
30
|
* capability passes (fail-open). Optional so non-controller callers stay 1-arg.
|
|
26
31
|
*/
|
|
27
|
-
|
|
32
|
+
/** Proves whether a token's delegation chain is unrevoked, without throwing. */
|
|
33
|
+
export type DelegationRevocationVerdict = (token: CapabilityToken, raw: string) => Promise<boolean>;
|
|
34
|
+
/** Adapt a proved verdict to kokuin's throwing VerifyTokenHook contract. */
|
|
35
|
+
export declare function createDelegationRevocationChecker(api: DelegationStoreAPI, methods?: MethodRegistry): VerifyTokenHook & {
|
|
36
|
+
verdict: DelegationRevocationVerdict;
|
|
37
|
+
};
|
|
@@ -1,25 +1,50 @@
|
|
|
1
1
|
import { createRevocationChecker } from '@kokuin/capability';
|
|
2
|
-
import { verifyToken } from '@kokuin/token';
|
|
3
|
-
/**
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
* grant is safe to hand over.
|
|
14
|
-
*
|
|
15
|
-
* `methods` resolves the record's issuer: a revocation whose `iss` is a
|
|
16
|
-
* `did:kokuin:` controller DID cannot verify from the identifier alone, so
|
|
17
|
-
* without the registry it fails to resolve, is dropped, and the revoked
|
|
18
|
-
* capability passes (fail-open). Optional so non-controller callers stay 1-arg.
|
|
19
|
-
*/ export function createDelegationRevocationChecker(api, methods) {
|
|
2
|
+
import { isIssuerKeyNotFoundError, isUnresolvableIssuerError, normalizeDID, verifyToken } from '@kokuin/token';
|
|
3
|
+
/** A revocation proved by kokuin after the store and resolver returned successfully. */ export class VerifiedRevocationError extends Error {
|
|
4
|
+
get brand() {
|
|
5
|
+
return '@kubun/store-delegation/VerifiedRevocationError';
|
|
6
|
+
}
|
|
7
|
+
constructor(message, options){
|
|
8
|
+
super(message, options);
|
|
9
|
+
this.name = 'VerifiedRevocationError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function createDelegationRevocationVerdictChecker(api, methods) {
|
|
20
13
|
// The backend is per-token: its `get` closes over the issuer of the
|
|
21
14
|
// capability being checked, which is only known once the hook is called.
|
|
22
15
|
return async (token, raw)=>{
|
|
16
|
+
let dependencyFault;
|
|
17
|
+
let hadDependencyFault = false;
|
|
18
|
+
let provedRevocation = false;
|
|
19
|
+
const recordFault = (error)=>{
|
|
20
|
+
if (!hadDependencyFault) {
|
|
21
|
+
dependencyFault = error;
|
|
22
|
+
hadDependencyFault = true;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
const trackedMethods = methods?.map((base)=>{
|
|
26
|
+
const wrap = (fn)=>async (...args)=>{
|
|
27
|
+
try {
|
|
28
|
+
return await fn(...args);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
recordFault(error);
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
return {
|
|
35
|
+
method: base.method,
|
|
36
|
+
resolve: wrap(base.resolve.bind(base)),
|
|
37
|
+
...base.resolveHistoric == null ? {} : {
|
|
38
|
+
resolveHistoric: wrap(base.resolveHistoric.bind(base))
|
|
39
|
+
},
|
|
40
|
+
...base.resolveDenySet == null ? {} : {
|
|
41
|
+
resolveDenySet: wrap(base.resolveDenySet.bind(base))
|
|
42
|
+
},
|
|
43
|
+
...base.resolveAgreementKey == null ? {} : {
|
|
44
|
+
resolveAgreementKey: wrap(base.resolveAgreementKey.bind(base))
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
});
|
|
23
48
|
const issuer = token.payload.iss;
|
|
24
49
|
const backend = {
|
|
25
50
|
async add () {
|
|
@@ -30,25 +55,60 @@ import { verifyToken } from '@kokuin/token';
|
|
|
30
55
|
// offering a second, lossy write path.
|
|
31
56
|
},
|
|
32
57
|
async get (jti) {
|
|
33
|
-
|
|
58
|
+
let row;
|
|
59
|
+
try {
|
|
60
|
+
row = await api.getRevocationByIssuer(jti, issuer);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
recordFault(error);
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
34
65
|
if (row == null) {
|
|
35
66
|
return undefined;
|
|
36
67
|
}
|
|
37
68
|
try {
|
|
38
69
|
// Carries `methods` so a controller-issued (`did:kokuin:`) record can
|
|
39
70
|
// resolve; must match the checker's own verify below or the two drift.
|
|
40
|
-
|
|
41
|
-
methods
|
|
71
|
+
const verified = await verifyToken(row.revocation_token, {
|
|
72
|
+
methods: trackedMethods
|
|
42
73
|
});
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
74
|
+
provedRevocation = normalizeDID(verified.payload.iss) === normalizeDID(issuer);
|
|
75
|
+
return verified;
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (hadDependencyFault) throw dependencyFault;
|
|
78
|
+
// A corrupt record is not revocation evidence. A resolver that cannot
|
|
79
|
+
// answer is different: the record may be genuine, so retry the frame.
|
|
80
|
+
if (isUnresolvableIssuerError(error)) throw error.cause ?? error;
|
|
81
|
+
if (isIssuerKeyNotFoundError(error)) return undefined;
|
|
82
|
+
if (error instanceof SyntaxError || error instanceof Error && (error.message === 'Invalid signature' || error.message === 'Token expired' || error.message === 'Token not yet valid' || error.message.startsWith('Invalid token') || error.message.startsWith('Unsupported token') || error.message.startsWith('Unsupported signature algorithm'))) {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
throw error;
|
|
47
86
|
}
|
|
48
87
|
}
|
|
49
88
|
};
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
89
|
+
try {
|
|
90
|
+
await createRevocationChecker(backend, {
|
|
91
|
+
methods: trackedMethods
|
|
92
|
+
})(token, raw);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (hadDependencyFault) throw dependencyFault;
|
|
95
|
+
// The store read and record verification proved this issuer's revocation.
|
|
96
|
+
// kokuin re-verifies that same record and throws its refusal; its spelling
|
|
97
|
+
// is irrelevant to this verdict. Any dependency throw was handled above.
|
|
98
|
+
if (provedRevocation) return true;
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/** Adapt a proved verdict to kokuin's throwing VerifyTokenHook contract. */ export function createDelegationRevocationChecker(api, methods) {
|
|
105
|
+
const verdict = createDelegationRevocationVerdictChecker(api, methods);
|
|
106
|
+
const checker = async (token, raw)=>{
|
|
107
|
+
if (await verdict(token, raw)) {
|
|
108
|
+
throw new VerifiedRevocationError(`Token revoked: ${token.payload.jti}`);
|
|
109
|
+
}
|
|
53
110
|
};
|
|
111
|
+
return Object.assign(checker, {
|
|
112
|
+
verdict
|
|
113
|
+
});
|
|
54
114
|
}
|
package/lib/tables.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { CreatedAtColumn, UpdatedAtColumn } from '@kubun/db-adapter';
|
|
2
|
-
import type { Insertable, Selectable } from 'kysely';
|
|
2
|
+
import type { Generated, Insertable, Selectable } from 'kysely';
|
|
3
3
|
export type DelegationTokenTable = {
|
|
4
4
|
jti: string;
|
|
5
5
|
grantor: string;
|
|
@@ -9,6 +9,7 @@ export type DelegationTokenTable = {
|
|
|
9
9
|
act: string;
|
|
10
10
|
exp: number;
|
|
11
11
|
hlc: string;
|
|
12
|
+
superseded: Generated<number>;
|
|
12
13
|
created_at: CreatedAtColumn;
|
|
13
14
|
updated_at: UpdatedAtColumn;
|
|
14
15
|
};
|
|
@@ -37,6 +38,7 @@ export type HeldRevocation = {
|
|
|
37
38
|
cap_exp: number | null;
|
|
38
39
|
};
|
|
39
40
|
export type DelegationStoreTables = {
|
|
41
|
+
kubun_delegation_token_keys: Pick<DelegationTokenTable, 'grantor' | 'audience' | 'resource'>;
|
|
40
42
|
kubun_delegation_tokens: DelegationTokenTable;
|
|
41
43
|
kubun_revoked_capabilities: RevokedCapabilityTable;
|
|
42
44
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubun/store-delegation",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"keywords": [],
|
|
5
5
|
"license": "see LICENSE.md",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -15,17 +15,17 @@
|
|
|
15
15
|
"LICENSE.md"
|
|
16
16
|
],
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@kokuin/capability": "^0.
|
|
19
|
-
"@kokuin/token": "^0.5.
|
|
20
|
-
"@kubun/db": "^0.
|
|
21
|
-
"@kubun/db-adapter": "^0.
|
|
22
|
-
"@kubun/logger": "^0.
|
|
23
|
-
"kysely": "^0.29.
|
|
18
|
+
"@kokuin/capability": "^0.4.2",
|
|
19
|
+
"@kokuin/token": "^0.5.1",
|
|
20
|
+
"@kubun/db": "^0.18.0",
|
|
21
|
+
"@kubun/db-adapter": "^0.18.0",
|
|
22
|
+
"@kubun/logger": "^0.18.0",
|
|
23
|
+
"kysely": "^0.29.7"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"@kubun/db-better-sqlite": "^0.
|
|
27
|
-
"@kubun/db-postgres": "^0.
|
|
28
|
-
"@testcontainers/postgresql": "^12.1
|
|
26
|
+
"@kubun/db-better-sqlite": "^0.18.0",
|
|
27
|
+
"@kubun/db-postgres": "^0.18.0",
|
|
28
|
+
"@testcontainers/postgresql": "^12.2.1"
|
|
29
29
|
},
|
|
30
30
|
"publishConfig": {
|
|
31
31
|
"access": "public"
|