@kubun/credential 0.14.1 → 0.14.2
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/index.d.ts +2 -0
- package/lib/index.js +1 -0
- package/lib/provider.d.ts +1 -5
- package/lib/provider.js +1 -55
- package/lib/refreshing-credential-provider.d.ts +50 -0
- package/lib/refreshing-credential-provider.js +185 -0
- package/package.json +2 -1
package/lib/index.d.ts
CHANGED
|
@@ -18,6 +18,8 @@ export type { CredentialEntryProvenance } from './provenance.js';
|
|
|
18
18
|
export { entryProvenanceFields } from './provenance.js';
|
|
19
19
|
export type { DBCredentialProviderParams } from './provider.js';
|
|
20
20
|
export { DBCredentialProvider, deserializeCredential, serializeCredential } from './provider.js';
|
|
21
|
+
export type { RefreshEvents, RefreshedEvent, RefreshFailedEvent, RefreshFailureCategory, RefreshingCredentialProviderParams, RefreshOutcome, } from './refreshing-credential-provider.js';
|
|
22
|
+
export { RefreshingCredentialProvider } from './refreshing-credential-provider.js';
|
|
21
23
|
export type { Argon2Cost, CredentialSuite, FactorInput, FactorInputs, SecretFactorKind, } from './suite.js';
|
|
22
24
|
export { DEFAULT_ARGON2_COST, getSuite, isSupportedSuite, SUITE_KUBUN_CRED_1 } from './suite.js';
|
|
23
25
|
export { CREDENTIAL_ADMINISTER_ACTION, credentialUserResource } from './vocabulary.js';
|
package/lib/index.js
CHANGED
|
@@ -8,5 +8,6 @@ export { compareBranchIDs, createCredentialOpSigner, credentialOpMatchesPut, cre
|
|
|
8
8
|
export { createPinThrottle, DEFAULT_BASE_DELAY, DEFAULT_FREE_ATTEMPTS, DEFAULT_MAX_DELAY } from './pin-throttle.js';
|
|
9
9
|
export { entryProvenanceFields } from './provenance.js';
|
|
10
10
|
export { DBCredentialProvider, deserializeCredential, serializeCredential } from './provider.js';
|
|
11
|
+
export { RefreshingCredentialProvider } from './refreshing-credential-provider.js';
|
|
11
12
|
export { DEFAULT_ARGON2_COST, getSuite, isSupportedSuite, SUITE_KUBUN_CRED_1 } from './suite.js';
|
|
12
13
|
export { CREDENTIAL_ADMINISTER_ACTION, credentialUserResource } from './vocabulary.js';
|
package/lib/provider.d.ts
CHANGED
|
@@ -1,13 +1,9 @@
|
|
|
1
|
-
import type { Credential, CredentialProvider
|
|
2
|
-
import type { Runtime } from '@sozai/runtime';
|
|
1
|
+
import type { Credential, CredentialProvider } from '@kubun/credential-types';
|
|
3
2
|
import type { CredentialAPI } from './api.js';
|
|
4
3
|
export declare function serializeCredential(credential: Credential): string;
|
|
5
4
|
export declare function deserializeCredential(raw: string | Record<string, unknown>): Credential;
|
|
6
5
|
export type DBCredentialProviderParams = {
|
|
7
6
|
api: CredentialAPI;
|
|
8
|
-
runtime: Runtime;
|
|
9
|
-
providers: Array<OAuthProviderDefinition>;
|
|
10
|
-
bufferSeconds?: number;
|
|
11
7
|
};
|
|
12
8
|
export declare class DBCredentialProvider implements CredentialProvider {
|
|
13
9
|
#private;
|
package/lib/provider.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
const DEFAULT_BUFFER_SECONDS = 300;
|
|
2
1
|
export function serializeCredential(credential) {
|
|
3
2
|
const serialized = {
|
|
4
3
|
accessToken: credential.accessToken,
|
|
@@ -41,24 +40,13 @@ export function deserializeCredential(raw) {
|
|
|
41
40
|
}
|
|
42
41
|
export class DBCredentialProvider {
|
|
43
42
|
#api;
|
|
44
|
-
#runtime;
|
|
45
|
-
#providers;
|
|
46
|
-
#bufferMs;
|
|
47
43
|
constructor(params){
|
|
48
44
|
this.#api = params.api;
|
|
49
|
-
this.#runtime = params.runtime;
|
|
50
|
-
this.#providers = params.providers;
|
|
51
|
-
this.#bufferMs = (params.bufferSeconds ?? DEFAULT_BUFFER_SECONDS) * 1000;
|
|
52
45
|
}
|
|
53
46
|
async get(providerName, ownerDID) {
|
|
54
47
|
const raw = await this.#api.getCredential(providerName, ownerDID);
|
|
55
48
|
if (raw == null) return null;
|
|
56
|
-
|
|
57
|
-
if (this.#isExpiringSoon(credential) && credential.refreshToken != null) {
|
|
58
|
-
const refreshed = await this.#refresh(providerName, ownerDID, credential, credential.refreshToken);
|
|
59
|
-
return refreshed ?? credential;
|
|
60
|
-
}
|
|
61
|
-
return credential;
|
|
49
|
+
return deserializeCredential(raw);
|
|
62
50
|
}
|
|
63
51
|
async set(params) {
|
|
64
52
|
const { providerName, ownerDID, credential, ownerWrappableDID, controllerWrappableDID } = params;
|
|
@@ -84,46 +72,4 @@ export class DBCredentialProvider {
|
|
|
84
72
|
async delete(providerName, ownerDID) {
|
|
85
73
|
await this.#api.deleteCredential(providerName, ownerDID);
|
|
86
74
|
}
|
|
87
|
-
#isExpiringSoon(credential) {
|
|
88
|
-
if (credential.expiresAt == null) return false;
|
|
89
|
-
return credential.expiresAt.getTime() - Date.now() < this.#bufferMs;
|
|
90
|
-
}
|
|
91
|
-
async #refresh(providerName, ownerDID, credential, refreshToken) {
|
|
92
|
-
const provider = this.#providers.find((p)=>p.name === providerName);
|
|
93
|
-
if (provider == null || provider.clientID == null || provider.clientSecret == null) {
|
|
94
|
-
return null;
|
|
95
|
-
}
|
|
96
|
-
try {
|
|
97
|
-
const response = await this.#runtime.fetch(provider.tokenEndpoint, {
|
|
98
|
-
method: 'POST',
|
|
99
|
-
headers: {
|
|
100
|
-
'Content-Type': 'application/x-www-form-urlencoded'
|
|
101
|
-
},
|
|
102
|
-
body: new URLSearchParams({
|
|
103
|
-
grant_type: 'refresh_token',
|
|
104
|
-
refresh_token: refreshToken,
|
|
105
|
-
client_id: provider.clientID,
|
|
106
|
-
client_secret: provider.clientSecret
|
|
107
|
-
})
|
|
108
|
-
});
|
|
109
|
-
if (!response.ok) return null;
|
|
110
|
-
const tokenData = await response.json();
|
|
111
|
-
const refreshed = {
|
|
112
|
-
accessToken: tokenData.access_token,
|
|
113
|
-
refreshToken: tokenData.refresh_token ?? credential.refreshToken,
|
|
114
|
-
expiresAt: tokenData.expires_in != null ? new Date(Date.now() + tokenData.expires_in * 1000) : undefined,
|
|
115
|
-
scopes: tokenData.scope?.split(' ') ?? credential.scopes,
|
|
116
|
-
accountLabel: credential.accountLabel,
|
|
117
|
-
metadata: credential.metadata
|
|
118
|
-
};
|
|
119
|
-
await this.set({
|
|
120
|
-
providerName,
|
|
121
|
-
ownerDID,
|
|
122
|
-
credential: refreshed
|
|
123
|
-
});
|
|
124
|
-
return refreshed;
|
|
125
|
-
} catch {
|
|
126
|
-
return null;
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
75
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Credential, CredentialProvider, OAuthProviderDefinition } from '@kubun/credential-types';
|
|
2
|
+
import type { Logger } from '@kubun/logger';
|
|
3
|
+
import { EventEmitter } from '@sozai/event';
|
|
4
|
+
import type { Runtime } from '@sozai/runtime';
|
|
5
|
+
export type RefreshFailureCategory = 'invalid_grant' | 'transient' | 'configuration' | 'persistence';
|
|
6
|
+
export type RefreshedEvent = {
|
|
7
|
+
providerName: string;
|
|
8
|
+
ownerDID: string;
|
|
9
|
+
scopes: Array<string>;
|
|
10
|
+
expiresAt: Date | undefined;
|
|
11
|
+
};
|
|
12
|
+
export type RefreshFailedEvent = {
|
|
13
|
+
providerName: string;
|
|
14
|
+
ownerDID: string;
|
|
15
|
+
category: RefreshFailureCategory;
|
|
16
|
+
};
|
|
17
|
+
export type RefreshEvents = {
|
|
18
|
+
refreshed: RefreshedEvent;
|
|
19
|
+
refreshFailed: RefreshFailedEvent;
|
|
20
|
+
};
|
|
21
|
+
export type RefreshOutcome = {
|
|
22
|
+
ok: true;
|
|
23
|
+
credential: Credential;
|
|
24
|
+
} | {
|
|
25
|
+
ok: false;
|
|
26
|
+
category: RefreshFailureCategory;
|
|
27
|
+
};
|
|
28
|
+
export type RefreshingCredentialProviderParams = {
|
|
29
|
+
inner: CredentialProvider;
|
|
30
|
+
runtime: Runtime;
|
|
31
|
+
providers: Array<OAuthProviderDefinition>;
|
|
32
|
+
bufferSeconds?: number;
|
|
33
|
+
logger?: Logger;
|
|
34
|
+
refreshInFlight?: Map<string, Promise<RefreshOutcome>>;
|
|
35
|
+
};
|
|
36
|
+
export declare class RefreshingCredentialProvider implements CredentialProvider {
|
|
37
|
+
#private;
|
|
38
|
+
constructor(params: RefreshingCredentialProviderParams);
|
|
39
|
+
get refreshEvents(): EventEmitter<RefreshEvents>;
|
|
40
|
+
peek(providerName: string, ownerDID: string): Promise<Credential | null>;
|
|
41
|
+
get(providerName: string, ownerDID: string): Promise<Credential | null>;
|
|
42
|
+
set(params: {
|
|
43
|
+
providerName: string;
|
|
44
|
+
ownerDID: string;
|
|
45
|
+
credential: Credential;
|
|
46
|
+
ownerWrappableDID?: string;
|
|
47
|
+
controllerWrappableDID?: string;
|
|
48
|
+
}): Promise<void>;
|
|
49
|
+
delete(providerName: string, ownerDID: string): Promise<void>;
|
|
50
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { normalizeDID } from '@kokuin/token';
|
|
2
|
+
import { EventEmitter } from '@sozai/event';
|
|
3
|
+
const DEFAULT_BUFFER_SECONDS = 300;
|
|
4
|
+
// Wraps a storage-only `CredentialProvider` and adds transparent OAuth refresh on
|
|
5
|
+
// read: a credential inside the expiry buffer with a refresh token is exchanged
|
|
6
|
+
// for a fresh one and written back through the inner provider. A failed exchange
|
|
7
|
+
// falls back to the stored (stale) credential rather than throwing, and reports
|
|
8
|
+
// the failure category through the `refreshEvents` emitter.
|
|
9
|
+
export class RefreshingCredentialProvider {
|
|
10
|
+
#inner;
|
|
11
|
+
#runtime;
|
|
12
|
+
#providers;
|
|
13
|
+
#bufferMs;
|
|
14
|
+
#events;
|
|
15
|
+
#refreshInFlight;
|
|
16
|
+
constructor(params){
|
|
17
|
+
this.#inner = params.inner;
|
|
18
|
+
this.#runtime = params.runtime;
|
|
19
|
+
this.#providers = params.providers;
|
|
20
|
+
this.#bufferMs = (params.bufferSeconds ?? DEFAULT_BUFFER_SECONDS) * 1000;
|
|
21
|
+
this.#events = new EventEmitter({
|
|
22
|
+
logger: params.logger
|
|
23
|
+
});
|
|
24
|
+
this.#refreshInFlight = params.refreshInFlight ?? new Map();
|
|
25
|
+
}
|
|
26
|
+
// Read-only view onto the refresh-outcome emitter. Consumers subscribe to
|
|
27
|
+
// `refreshed`/`refreshFailed`; emission is owned here.
|
|
28
|
+
get refreshEvents() {
|
|
29
|
+
return this.#events;
|
|
30
|
+
}
|
|
31
|
+
// Non-refreshing existence read: returns the stored credential straight from the
|
|
32
|
+
// inner storage provider, skipping the expiry check, token exchange and outcome
|
|
33
|
+
// emit that `get` performs. A caller testing presence before deletion uses this
|
|
34
|
+
// so a disconnect never triggers a spurious refresh (and a stray refresh outcome)
|
|
35
|
+
// immediately before it removes the credential.
|
|
36
|
+
peek(providerName, ownerDID) {
|
|
37
|
+
return this.#inner.get(providerName, ownerDID);
|
|
38
|
+
}
|
|
39
|
+
async get(providerName, ownerDID) {
|
|
40
|
+
const credential = await this.#inner.get(providerName, ownerDID);
|
|
41
|
+
if (credential == null) return null;
|
|
42
|
+
if (this.#isExpiringSoon(credential) && credential.refreshToken != null) {
|
|
43
|
+
const refreshed = await this.#refresh(providerName, ownerDID, credential, credential.refreshToken);
|
|
44
|
+
return refreshed ?? credential;
|
|
45
|
+
}
|
|
46
|
+
return credential;
|
|
47
|
+
}
|
|
48
|
+
set(params) {
|
|
49
|
+
return this.#inner.set(params);
|
|
50
|
+
}
|
|
51
|
+
delete(providerName, ownerDID) {
|
|
52
|
+
return this.#inner.delete(providerName, ownerDID);
|
|
53
|
+
}
|
|
54
|
+
#isExpiringSoon(credential) {
|
|
55
|
+
if (credential.expiresAt == null) return false;
|
|
56
|
+
return credential.expiresAt.getTime() - Date.now() < this.#bufferMs;
|
|
57
|
+
}
|
|
58
|
+
// The single point where a refresh outcome is produced. A `null` return is the
|
|
59
|
+
// stale-fallback signal `get` honours on every failure category.
|
|
60
|
+
//
|
|
61
|
+
// Concurrent refreshes of the same provider+owner are collapsed by the shared
|
|
62
|
+
// single-flight map. The first caller to find no in-flight promise is the
|
|
63
|
+
// coordinator: it runs the one `#exchange`, stores its promise so overlapping
|
|
64
|
+
// callers await the same round-trip, and — alone — emits the outcome through
|
|
65
|
+
// `fire` (never awaited, never rethrown, so no subscriber can break refresh or
|
|
66
|
+
// `get`). Followers await the coordinator's promise and reuse its outcome for
|
|
67
|
+
// their own stale-fallback decision without emitting a second event. The key is
|
|
68
|
+
// normalized so the two `did:peer:4` spellings of one owner share an entry.
|
|
69
|
+
async #refresh(providerName, ownerDID, credential, refreshToken) {
|
|
70
|
+
const key = `${providerName}:${normalizeDID(ownerDID)}`;
|
|
71
|
+
const inFlight = this.#refreshInFlight.get(key);
|
|
72
|
+
if (inFlight != null) {
|
|
73
|
+
const outcome = await inFlight;
|
|
74
|
+
return outcome.ok ? outcome.credential : null;
|
|
75
|
+
}
|
|
76
|
+
const exchange = this.#exchange(providerName, ownerDID, credential, refreshToken);
|
|
77
|
+
this.#refreshInFlight.set(key, exchange);
|
|
78
|
+
let outcome;
|
|
79
|
+
try {
|
|
80
|
+
outcome = await exchange;
|
|
81
|
+
} finally{
|
|
82
|
+
this.#refreshInFlight.delete(key);
|
|
83
|
+
}
|
|
84
|
+
if (outcome.ok) {
|
|
85
|
+
this.#events.fire('refreshed', {
|
|
86
|
+
providerName,
|
|
87
|
+
ownerDID,
|
|
88
|
+
scopes: outcome.credential.scopes,
|
|
89
|
+
expiresAt: outcome.credential.expiresAt
|
|
90
|
+
});
|
|
91
|
+
return outcome.credential;
|
|
92
|
+
}
|
|
93
|
+
this.#events.fire('refreshFailed', {
|
|
94
|
+
providerName,
|
|
95
|
+
ownerDID,
|
|
96
|
+
category: outcome.category
|
|
97
|
+
});
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
// Performs the token exchange and classifies the result. Concurrent callers for
|
|
101
|
+
// one provider+owner never reach here twice: `#refresh` shares a single pending
|
|
102
|
+
// `#exchange` promise through the single-flight map. Returns a discriminated
|
|
103
|
+
// outcome and does not emit or fall back itself.
|
|
104
|
+
async #exchange(providerName, ownerDID, credential, refreshToken) {
|
|
105
|
+
const provider = this.#providers.find((p)=>p.name === providerName);
|
|
106
|
+
if (provider == null || provider.clientID == null || provider.clientSecret == null) {
|
|
107
|
+
return {
|
|
108
|
+
ok: false,
|
|
109
|
+
category: 'configuration'
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
let response;
|
|
113
|
+
try {
|
|
114
|
+
response = await this.#runtime.fetch(provider.tokenEndpoint, {
|
|
115
|
+
method: 'POST',
|
|
116
|
+
headers: {
|
|
117
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
118
|
+
},
|
|
119
|
+
body: new URLSearchParams({
|
|
120
|
+
grant_type: 'refresh_token',
|
|
121
|
+
refresh_token: refreshToken,
|
|
122
|
+
client_id: provider.clientID,
|
|
123
|
+
client_secret: provider.clientSecret
|
|
124
|
+
})
|
|
125
|
+
});
|
|
126
|
+
} catch {
|
|
127
|
+
return {
|
|
128
|
+
ok: false,
|
|
129
|
+
category: 'transient'
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
if (!response.ok) {
|
|
133
|
+
return {
|
|
134
|
+
ok: false,
|
|
135
|
+
category: await this.#classifyErrorResponse(response)
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
let tokenData;
|
|
139
|
+
try {
|
|
140
|
+
tokenData = await response.json();
|
|
141
|
+
} catch {
|
|
142
|
+
return {
|
|
143
|
+
ok: false,
|
|
144
|
+
category: 'transient'
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
const refreshed = {
|
|
148
|
+
accessToken: tokenData.access_token,
|
|
149
|
+
refreshToken: tokenData.refresh_token ?? credential.refreshToken,
|
|
150
|
+
expiresAt: tokenData.expires_in != null ? new Date(Date.now() + tokenData.expires_in * 1000) : undefined,
|
|
151
|
+
scopes: tokenData.scope?.split(' ') ?? credential.scopes,
|
|
152
|
+
accountLabel: credential.accountLabel,
|
|
153
|
+
metadata: credential.metadata
|
|
154
|
+
};
|
|
155
|
+
try {
|
|
156
|
+
await this.#inner.set({
|
|
157
|
+
providerName,
|
|
158
|
+
ownerDID,
|
|
159
|
+
credential: refreshed
|
|
160
|
+
});
|
|
161
|
+
} catch {
|
|
162
|
+
return {
|
|
163
|
+
ok: false,
|
|
164
|
+
category: 'persistence'
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
ok: true,
|
|
169
|
+
credential: refreshed
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
// Distinguishes a confirmed OAuth invalidation from a generic non-OK status.
|
|
173
|
+
// The standard OAuth2 error response is `{ "error": "invalid_grant", ... }` in
|
|
174
|
+
// the JSON body of a 400; anything else — including an empty or non-JSON body —
|
|
175
|
+
// is treated as transient so a stale-but-valid token is never abandoned.
|
|
176
|
+
async #classifyErrorResponse(response) {
|
|
177
|
+
try {
|
|
178
|
+
const body = await response.json();
|
|
179
|
+
if (body?.error === 'invalid_grant') return 'invalid_grant';
|
|
180
|
+
} catch {
|
|
181
|
+
// Empty or non-JSON error body: cannot confirm invalidation.
|
|
182
|
+
}
|
|
183
|
+
return 'transient';
|
|
184
|
+
}
|
|
185
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubun/credential",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.2",
|
|
4
4
|
"keywords": [],
|
|
5
5
|
"license": "see LICENSE.md",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"@noble/ciphers": "^2.4.0",
|
|
27
27
|
"@noble/hashes": "^2.4.0",
|
|
28
28
|
"@sozai/codec": "^0.4.0",
|
|
29
|
+
"@sozai/event": "^0.1.3",
|
|
29
30
|
"@sozai/runtime": "^0.1.0"
|
|
30
31
|
},
|
|
31
32
|
"devDependencies": {
|