@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 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, OAuthProviderDefinition } from '@kubun/credential-types';
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
- const credential = deserializeCredential(raw);
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.1",
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": {