@kubun/credential 0.13.0 → 0.14.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 ADDED
@@ -0,0 +1,69 @@
1
+ import { type MethodRegistry } from '@kokuin/token';
2
+ import type { StoreProvider } from '@kubun/db';
3
+ import type { Logger } from '@kubun/logger';
4
+ import type { CredentialManager } from './manager.js';
5
+ /**
6
+ * The credential seam: resolves its `(providerName, ownerDID) → entryID` pointer
7
+ * through the alias layer of the credential store, and its entries through a
8
+ * `CredentialManager`. Every `ownerDID` is normalized before it reaches the
9
+ * alias, so an un-normalized equality key cannot split one account across two
10
+ * rows: a credential invisible under one spelling and un-revokable under the
11
+ * other.
12
+ */
13
+ export type CredentialAPI = {
14
+ getCredential: (providerName: string, ownerDID: string) => Promise<string | null>;
15
+ getCredentialProvenance: (providerName: string, ownerDID: string) => Promise<{
16
+ updatedAt: string | null;
17
+ writerDID: string | null;
18
+ } | null>;
19
+ /**
20
+ * Re-encrypt an existing credential. Refuses when none exists, because
21
+ * creating one is a different decision — see `createCredential`.
22
+ */
23
+ setCredential: (providerName: string, ownerDID: string, credential: string) => Promise<void>;
24
+ /**
25
+ * Mint the key, its two wrappings and the entry, and record the pointer.
26
+ *
27
+ * Split from `setCredential` so the headless refresh path cannot mint a key.
28
+ * A minted key fixes who will ever be able to read it, and a refresh running
29
+ * with no owner in hand would mint one wrapped to the server alone — locking
30
+ * the account owner out of their own credential with nothing failing.
31
+ */
32
+ createCredential: (params: {
33
+ providerName: string;
34
+ ownerDID: string;
35
+ ownerWrappableDID: string;
36
+ controllerWrappableDID?: string;
37
+ credential: string;
38
+ }) => Promise<void>;
39
+ deleteCredential: (providerName: string, ownerDID: string) => Promise<void>;
40
+ /**
41
+ * The controller resolvers for this API's (request-scoped) transaction
42
+ * provider — the exact `methods` the credential mint passes to `createKey`.
43
+ * Exposed so the OAuth pre-flight can ask `canWrapTo` with the same resolver
44
+ * the mint uses, rather than failing closed on a `did:kokuin:` owner whose
45
+ * agreement key is resolvable only through the controller resolver.
46
+ */
47
+ getControllerMethods: () => MethodRegistry;
48
+ };
49
+ export type CreateCredentialAPIParams = {
50
+ stores: StoreProvider;
51
+ logger: Logger;
52
+ /**
53
+ * Takes the `StoreProvider` rather than a manager, because during a mutation
54
+ * that provider is the transaction and a manager built over the base one
55
+ * would read outside it.
56
+ */
57
+ getCredentialManager: (stores: StoreProvider) => Promise<CredentialManager>;
58
+ /** The engine's own DID, in a form that can be encrypted to. */
59
+ serverWrappableDID: string;
60
+ /**
61
+ * Resolvers for wrapping recipients that carry no key material of their own
62
+ * (`did:kokuin:`), scoped to the request's transaction provider so a resolve
63
+ * reads the mutation's tx, never a second connection. A self-contained
64
+ * recipient resolves with an empty registry, so callers with no controller
65
+ * recipient in play still pass `() => []`.
66
+ */
67
+ getControllerMethods: (stores: StoreProvider) => MethodRegistry;
68
+ };
69
+ export declare function createCredentialAPI(params: CreateCredentialAPIParams): CredentialAPI;
package/lib/api.js ADDED
@@ -0,0 +1,124 @@
1
+ import { normalizeDID } from '@kokuin/token';
2
+ import { getCredentialStore } from '@kubun/store-credential';
3
+ import { fromUTF, toUTF } from '@sozai/codec';
4
+ import { CredentialSupersededBranch } from './errors.js';
5
+ export function createCredentialAPI(params) {
6
+ const getAliasStore = ()=>getCredentialStore(params.stores);
7
+ const getCredentials = ()=>params.getCredentialManager(params.stores);
8
+ return {
9
+ async getCredential (providerName, ownerDID) {
10
+ const store = await getAliasStore();
11
+ const entryID = await store.getAliasEntryID(providerName, normalizeDID(ownerDID));
12
+ if (entryID == null) return null;
13
+ try {
14
+ return toUTF(await (await getCredentials()).readEntry(entryID));
15
+ } catch (error) {
16
+ if (error instanceof CredentialSupersededBranch) {
17
+ params.logger.warn('Credential entry {entryID} for provider {providerName} belongs to a superseded branch', {
18
+ providerName,
19
+ entryID
20
+ });
21
+ return null;
22
+ }
23
+ throw error;
24
+ }
25
+ },
26
+ async getCredentialProvenance (providerName, ownerDID) {
27
+ // Reflects the current pointer, not the winning branch — no superseded-branch guard
28
+ // (it never decrypts). Callers must gate on getCredential() != null so a loser-branch
29
+ // pointer's provenance is never surfaced.
30
+ const store = await getAliasStore();
31
+ const entryID = await store.getAliasEntryID(providerName, normalizeDID(ownerDID));
32
+ if (entryID == null) return null;
33
+ return (await getCredentials()).getEntryProvenance(entryID);
34
+ },
35
+ async setCredential (providerName, ownerDID, credential) {
36
+ const store = await getAliasStore();
37
+ const entryID = await store.getAliasEntryID(providerName, normalizeDID(ownerDID));
38
+ if (entryID == null) {
39
+ throw new Error(`No connector credential to update for provider "${providerName}"; create it through the authorization flow`);
40
+ }
41
+ // Opens the key through the server's own wrapping and rewrites the entry
42
+ // under it. No key is minted and no wrapping is touched, so a refresh
43
+ // cannot change who can read the credential.
44
+ await (await getCredentials()).updateEntry(entryID, fromUTF(credential));
45
+ },
46
+ async createCredential (input) {
47
+ const { providerName, ownerDID, ownerWrappableDID, credential } = input;
48
+ const store = await getAliasStore();
49
+ const credentials = await getCredentials();
50
+ // Wrapping to a DID needs only that DID's published key, so an engine that
51
+ // cannot decrypt would mint this key perfectly and then be unable to read
52
+ // it — a failure that surfaces at the first headless sync, far from here.
53
+ // This is where `cipher == null` used to fail closed, and it still does.
54
+ if (!credentials.availableFactors().includes('did')) {
55
+ throw new Error(`Cannot create a connector credential for "${providerName}": this engine's identity cannot decrypt, so it could never read back what it stored`);
56
+ }
57
+ // Two wrappings from the first write, not one plus a later grant: the
58
+ // server has to read this to run sync, and the owner has to be able to
59
+ // rotate and revoke it. Owner is authority, a different question from who
60
+ // can decrypt: when a controller is supplied it is the owner, so a device
61
+ // revoke goes through the controller's capability chain rather than the
62
+ // device self-authorizing; absent one the device owns its own key.
63
+ //
64
+ // The two spellings below are deliberately different questions about the
65
+ // same identity, and NOT interchangeable: `owner_did` is an equality key
66
+ // and so is normalized, while a recipient is encrypted to and must keep
67
+ // its wrappable form. `ownerWrappableDID` is therefore NOT normalized.
68
+ // Normalizing a `did:peer:4` recipient hands the short form to
69
+ // `deriveSharedSecret`, which resolves no document and no agreement key —
70
+ // do not fold these together.
71
+ const wrappings = [
72
+ [
73
+ {
74
+ kind: 'did',
75
+ recipientDID: ownerWrappableDID
76
+ }
77
+ ],
78
+ [
79
+ {
80
+ kind: 'did',
81
+ recipientDID: params.serverWrappableDID
82
+ }
83
+ ]
84
+ ];
85
+ // A recovery recipient (e.g. a controller / seed-holder), so the credential
86
+ // survives device loss. Opt-in: absent when the caller supplied none. Kept
87
+ // in its wrappable form for the same reason as the two above — it is
88
+ // encrypted to, not compared against.
89
+ if (input.controllerWrappableDID != null) {
90
+ wrappings.push([
91
+ {
92
+ kind: 'did',
93
+ recipientDID: input.controllerWrappableDID
94
+ }
95
+ ]);
96
+ }
97
+ // The controller owns the key when supplied (its canonical DID equals the
98
+ // normalized wrappable form); the account pointer below stays keyed on the
99
+ // device — ownership and account scoping are separate concerns.
100
+ const keyOwnerDID = input.controllerWrappableDID != null ? normalizeDID(input.controllerWrappableDID) : normalizeDID(ownerDID);
101
+ const keyID = await credentials.createKey({
102
+ ownerDID: keyOwnerDID,
103
+ wrappings,
104
+ methods: params.getControllerMethods(params.stores)
105
+ });
106
+ const entryID = await credentials.putEntry(keyID, fromUTF(credential));
107
+ await store.setAliasEntryID(providerName, normalizeDID(ownerDID), entryID);
108
+ },
109
+ async deleteCredential (providerName, ownerDID) {
110
+ const store = await getAliasStore();
111
+ const owner = normalizeDID(ownerDID);
112
+ const entryID = await store.getAliasEntryID(providerName, owner);
113
+ await store.deleteAliasEntryID(providerName, owner);
114
+ if (entryID != null) {
115
+ // The pointer and the entry go together. Leaving the entry would keep
116
+ // the token readable by anyone who kept its id after a disconnect.
117
+ await (await getCredentials()).deleteEntry(entryID);
118
+ }
119
+ },
120
+ getControllerMethods () {
121
+ return params.getControllerMethods(params.stores);
122
+ }
123
+ };
124
+ }
package/lib/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ export type { CreateCredentialAPIParams, CredentialAPI } from './api.js';
2
+ export { createCredentialAPI } from './api.js';
1
3
  export type { CredentialAuthority, CredentialAuthorityParams, CredentialAuthorization, } from './authority.js';
2
4
  export { createDeviceAuthority } from './authority.js';
3
5
  export type { BuildEntryParams, BuildKeyParams, BuiltEntry, BuiltKey, BuiltWrapping, CredentialKeyMaterial, } from './builder.js';
@@ -14,6 +16,8 @@ export type { PinThrottle, PinThrottleParams } from './pin-throttle.js';
14
16
  export { createPinThrottle, DEFAULT_BASE_DELAY, DEFAULT_FREE_ATTEMPTS, DEFAULT_MAX_DELAY, } from './pin-throttle.js';
15
17
  export type { CredentialEntryProvenance } from './provenance.js';
16
18
  export { entryProvenanceFields } from './provenance.js';
19
+ export type { DBCredentialProviderParams } from './provider.js';
20
+ export { DBCredentialProvider, deserializeCredential, serializeCredential } from './provider.js';
17
21
  export type { Argon2Cost, CredentialSuite, FactorInput, FactorInputs, SecretFactorKind, } from './suite.js';
18
22
  export { DEFAULT_ARGON2_COST, getSuite, isSupportedSuite, SUITE_KUBUN_CRED_1 } from './suite.js';
19
23
  export { CREDENTIAL_ADMINISTER_ACTION, credentialUserResource } from './vocabulary.js';
package/lib/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ export { createCredentialAPI } from './api.js';
1
2
  export { createDeviceAuthority } from './authority.js';
2
3
  export { buildCredentialEntry, buildCredentialKey } from './builder.js';
3
4
  export { credentialEntriesDigest, credentialEntryDigest, credentialKeyDigest, credentialWrappingDigest, digestParts } from './digest.js';
@@ -6,5 +7,6 @@ export { createCredentialManager } from './manager.js';
6
7
  export { compareBranchIDs, createCredentialOpSigner, credentialOpMatchesPut, credentialOpMatchesRow, credentialPutDigest, credentialRowDigest, selectWinningBranch, verifyCredentialOp } from './op.js';
7
8
  export { createPinThrottle, DEFAULT_BASE_DELAY, DEFAULT_FREE_ATTEMPTS, DEFAULT_MAX_DELAY } from './pin-throttle.js';
8
9
  export { entryProvenanceFields } from './provenance.js';
10
+ export { DBCredentialProvider, deserializeCredential, serializeCredential } from './provider.js';
9
11
  export { DEFAULT_ARGON2_COST, getSuite, isSupportedSuite, SUITE_KUBUN_CRED_1 } from './suite.js';
10
12
  export { CREDENTIAL_ADMINISTER_ACTION, credentialUserResource } from './vocabulary.js';
@@ -0,0 +1,24 @@
1
+ import type { Credential, CredentialProvider, OAuthProviderDefinition } from '@kubun/credential-types';
2
+ import type { Runtime } from '@sozai/runtime';
3
+ import type { CredentialAPI } from './api.js';
4
+ export declare function serializeCredential(credential: Credential): string;
5
+ export declare function deserializeCredential(raw: string | Record<string, unknown>): Credential;
6
+ export type DBCredentialProviderParams = {
7
+ api: CredentialAPI;
8
+ runtime: Runtime;
9
+ providers: Array<OAuthProviderDefinition>;
10
+ bufferSeconds?: number;
11
+ };
12
+ export declare class DBCredentialProvider implements CredentialProvider {
13
+ #private;
14
+ constructor(params: DBCredentialProviderParams);
15
+ get(providerName: string, ownerDID: string): Promise<Credential | null>;
16
+ set(params: {
17
+ providerName: string;
18
+ ownerDID: string;
19
+ credential: Credential;
20
+ ownerWrappableDID?: string;
21
+ controllerWrappableDID?: string;
22
+ }): Promise<void>;
23
+ delete(providerName: string, ownerDID: string): Promise<void>;
24
+ }
@@ -0,0 +1,129 @@
1
+ const DEFAULT_BUFFER_SECONDS = 300;
2
+ export function serializeCredential(credential) {
3
+ const serialized = {
4
+ accessToken: credential.accessToken,
5
+ scopes: credential.scopes
6
+ };
7
+ if (credential.refreshToken != null) {
8
+ serialized.refreshToken = credential.refreshToken;
9
+ }
10
+ if (credential.expiresAt != null) {
11
+ serialized.expiresAt = credential.expiresAt.toISOString();
12
+ }
13
+ if (credential.accountLabel != null) {
14
+ serialized.accountLabel = credential.accountLabel;
15
+ }
16
+ if (credential.metadata != null) {
17
+ serialized.metadata = credential.metadata;
18
+ }
19
+ return JSON.stringify(serialized);
20
+ }
21
+ export function deserializeCredential(raw) {
22
+ // ParseJSONResultsPlugin may have already parsed the JSON string
23
+ const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
24
+ const credential = {
25
+ accessToken: parsed.accessToken,
26
+ scopes: parsed.scopes
27
+ };
28
+ if (parsed.refreshToken != null) {
29
+ credential.refreshToken = parsed.refreshToken;
30
+ }
31
+ if (parsed.expiresAt != null) {
32
+ credential.expiresAt = new Date(parsed.expiresAt);
33
+ }
34
+ if (parsed.accountLabel != null) {
35
+ credential.accountLabel = parsed.accountLabel;
36
+ }
37
+ if (parsed.metadata != null) {
38
+ credential.metadata = parsed.metadata;
39
+ }
40
+ return credential;
41
+ }
42
+ export class DBCredentialProvider {
43
+ #api;
44
+ #runtime;
45
+ #providers;
46
+ #bufferMs;
47
+ constructor(params){
48
+ this.#api = params.api;
49
+ this.#runtime = params.runtime;
50
+ this.#providers = params.providers;
51
+ this.#bufferMs = (params.bufferSeconds ?? DEFAULT_BUFFER_SECONDS) * 1000;
52
+ }
53
+ async get(providerName, ownerDID) {
54
+ const raw = await this.#api.getCredential(providerName, ownerDID);
55
+ 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;
62
+ }
63
+ async set(params) {
64
+ const { providerName, ownerDID, credential, ownerWrappableDID, controllerWrappableDID } = params;
65
+ const serialized = serializeCredential(credential);
66
+ // Update when one exists, mint only when it does not. Dispatching on
67
+ // presence rather than on the caller keeps the refresh path unable to mint
68
+ // even when it does hold a wrappable DID.
69
+ if (await this.#api.getCredential(providerName, ownerDID) != null) {
70
+ await this.#api.setCredential(providerName, ownerDID, serialized);
71
+ return;
72
+ }
73
+ if (ownerWrappableDID == null) {
74
+ throw new Error(`Cannot create a connector credential for "${providerName}" without the owner's wrappable DID`);
75
+ }
76
+ await this.#api.createCredential({
77
+ providerName,
78
+ ownerDID,
79
+ ownerWrappableDID,
80
+ controllerWrappableDID,
81
+ credential: serialized
82
+ });
83
+ }
84
+ async delete(providerName, ownerDID) {
85
+ await this.#api.deleteCredential(providerName, ownerDID);
86
+ }
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubun/credential",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "keywords": [],
5
5
  "license": "see LICENSE.md",
6
6
  "sideEffects": false,
@@ -17,17 +17,19 @@
17
17
  "dependencies": {
18
18
  "@kokuin/jwe": "^0.1.0",
19
19
  "@kokuin/token": "^0.5.0",
20
+ "@kubun/credential-types": "^0.14.0",
21
+ "@kubun/db": "^0.14.0",
22
+ "@kubun/hlc": "^0.14.0",
23
+ "@kubun/id": "^0.14.0",
24
+ "@kubun/logger": "^0.14.0",
25
+ "@kubun/store-credential": "^0.14.0",
20
26
  "@noble/ciphers": "^2.3.0",
21
27
  "@noble/hashes": "^2.3.0",
22
28
  "@sozai/codec": "^0.4.0",
23
- "@sozai/runtime": "^0.1.0",
24
- "@kubun/hlc": "^0.13.0",
25
- "@kubun/store-credential": "^0.13.0",
26
- "@kubun/id": "^0.13.0"
29
+ "@sozai/runtime": "^0.1.0"
27
30
  },
28
31
  "devDependencies": {
29
- "@kubun/db-better-sqlite": "^0.13.0",
30
- "@kubun/db": "^0.13.0"
32
+ "@kubun/db-better-sqlite": "^0.14.0"
31
33
  },
32
34
  "publishConfig": {
33
35
  "access": "public"