UNPKG

did-session

Version:

Manage user DIDs in a web environment

394 lines (393 loc) 14.8 kB
/** * Manages user account DIDs in web based environments. * * ## Purpose * * Manages, creates and authorizes a DID session key for a user. Returns an authenticated DIDs instance * to be used in other Ceramic libraries. Supports did:pkh for blockchain accounts with Sign-In with * Ethereum and CACAO for authorization. * * ## Installation * * ```sh * npm install did-session * ``` * * ## Usage * * Authorize and use DIDs where needed. Import the AuthMethod you need, Ethereum accounts used here for example. * * ```js * import { DIDSession } from 'did-session' * import { EthereumWebAuth, getAccountId } from '@didtools/pkh-ethereum' * * const ethProvider = // import/get your web3 eth provider * const addresses = await ethProvider.request({ method: 'eth_requestAccounts' }) * const accountId = await getAccountId(ethProvider, addresses[0]) * const authMethod = await EthereumWebAuth.getAuthMethod(ethprovider, accountId) * * const session = await DIDSession.get(accountId, authMethod, { resources: [...]}) * * // Uses DIDs in ceramic & glaze libraries, ie * const ceramic = new CeramicClient() * ceramic.did = session.did * * // pass ceramic instance where needed * * ``` * * Additional helper functions are available to help you manage a session lifecycle and the user experience. * * ```js * // Check if authorized or created from existing session string * didsession.hasSession * * // Check if session expired * didsession.isExpired * * // Get resources session is authorized for * didsession.authorizations * * // Check number of seconds till expiration, may want to re auth user at a time before expiration * didsession.expiresInSecs * ``` * * ## Configuration * * The resources your app needs to write access to must be passed during authorization. Resources are an array * of Model Stream Ids or Streams Ids. Typically you will just pass resources from `@composedb` libraries as * you will already manage your Composites and Models there. For example: * * ```js * import { ComposeClient } from '@composedb/client' * * //... Reference above and `@composedb` docs for additional configuration here * * const client = new ComposeClient({ceramic, definition}) * const resources = client.resources * const session = await DIDSession.get(accountId, authMethod, { resources }) * client.setDID(session.did) * ``` * * By default a session will expire in 1 week. You can change this time by passing the `expiresInSecs` option to * indicate how many seconds from the current time you want this session to expire. * * ```js * const oneDay = 60 * 60 * 24 * const session = await DIDSession.get(accountId, authMethod, { resources: [...], expiresInSecs: oneDay }) * ``` * * A domain/app name is used when making requests, by default in a browser based environment the library will use * the domain name of your app. If you are using the library in a non web based environment you will need to pass * the `domain` option otherwise an error will thrown. * * ```js * const session = await DIDSession.get(accountId, authMethod, { resources: [...], domain: 'YourAppName' }) * ``` * * ## Upgrading from `@glazed/did-session` to `did-session` * * `authorize` changes to a static method which returns a did-session instance and `getDID()` becomes a `did` getter. For example: * * ```js * // Before @glazed/did-session * const session = new DIDSession({ authProvider }) * const did = await session.authorize() * * // Now did-session * const session = await DIDSession.get(accountId, authMethod, { resources: [...]}) * const did = session.did * ``` * * ## Upgrading from `did-session@0.x.x` to `did-session@1.x.x` * * AuthProviders change to AuthMethod interfaces. Similarly you can import the auth libraries you need. How you configure and manage * these AuthMethods may differ, but each will return an AuthMethod function to be used with did-session. * * ```js * // Before with v0.x.x * ... * import { EthereumAuthProvider } from '@ceramicnetwork/blockchain-utils-linking' * * const ethProvider = // import/get your web3 eth provider * const addresses = await ethProvider.request({ method: 'eth_requestAccounts' }) * const authProvider = new EthereumAuthProvider(ethProvider, addresses[0]) * const session = new DIDSession({ authProvider }) * const did = await session.authorize() * * // Now did-session@1.0.0 * ... * import { EthereumWebAuth, getAccountId } from '@didtools/pkh-ethereum' * * const ethProvider = // import/get your web3 eth provider * const addresses = await ethProvider.request({ method: 'eth_requestAccounts' }) * const accountId = await getAccountId(ethProvider, addresses[0]) * const authMethod = await EthereumWebAuth.getAuthMethod(ethProvider, accountId) * const session = await DIDSession.get(accountId, authMethod, { resources: [...]}) * const did = session.did * ``` * * @module did-session */ function _check_private_redeclaration(obj, privateCollection) { if (privateCollection.has(obj)) { throw new TypeError("Cannot initialize the same private elements twice on an object"); } } function _class_apply_descriptor_get(receiver, descriptor) { if (descriptor.get) { return descriptor.get.call(receiver); } return descriptor.value; } function _class_apply_descriptor_set(receiver, descriptor, value) { if (descriptor.set) { descriptor.set.call(receiver, value); } else { if (!descriptor.writable) { throw new TypeError("attempted to set read only private field"); } descriptor.value = value; } } function _class_extract_field_descriptor(receiver, privateMap, action) { if (!privateMap.has(receiver)) { throw new TypeError("attempted to " + action + " private field on non-instance"); } return privateMap.get(receiver); } function _class_private_field_get(receiver, privateMap) { var descriptor = _class_extract_field_descriptor(receiver, privateMap, "get"); return _class_apply_descriptor_get(receiver, descriptor); } function _class_private_field_init(obj, privateMap, value) { _check_private_redeclaration(obj, privateMap); privateMap.set(obj, value); } function _class_private_field_set(receiver, privateMap, value) { var descriptor = _class_extract_field_descriptor(receiver, privateMap, "set"); _class_apply_descriptor_set(receiver, descriptor, value); return value; } import { Ed25519Provider } from 'key-did-provider-ed25519'; import { WebcryptoProvider, generateP256KeyPair } from '@didtools/key-webcrypto'; import KeyDidResolver from 'key-did-resolver'; import { randomBytes } from '@stablelib/random'; import { DID } from 'dids'; import { AccountId } from 'caip'; import * as u8a from 'uint8arrays'; import { SessionStore } from './sessionStore.js'; export async function createDIDKey(seed) { const didProvider = new Ed25519Provider(seed || randomBytes(32)); const didKey = new DID({ provider: didProvider, resolver: KeyDidResolver.getResolver() }); await didKey.authenticate(); return didKey; } export async function createDIDCacao(didKey, cacao) { const didWithCap = didKey.withCapability(cacao); await didWithCap.authenticate(); return didWithCap; } export function getAccountIdByDID(did) { return new AccountId(did.slice(8)); } function JSONToBase64url(object) { return u8a.toString(u8a.fromString(JSON.stringify(object)), 'base64url'); } function base64urlToJSON(s) { return JSON.parse(u8a.toString(u8a.fromString(s, 'base64url'))); } function bytesToBase64(b) { return u8a.toString(b, 'base64pad'); } function base64ToBytes(s) { return u8a.fromString(s, 'base64pad'); } export function cacaoContainsResources(cacao, resources) { return resources.every((res)=>cacao.p.resources?.includes(res)); } function isExpired(expTime) { if (!expTime) return false; return Date.parse(expTime) < Date.now(); } var _did = /*#__PURE__*/ new WeakMap(), _keySeed = /*#__PURE__*/ new WeakMap(), _cacao = /*#__PURE__*/ new WeakMap(); /** * DID Session * * ```sh * import { DIDSession } from 'did-session' * ``` */ export class DIDSession { /** * Request authorization for session */ static async authorize(authMethod, authOpts = {}) { if (!authOpts.resources || authOpts.resources.length === 0) throw new Error('Required: resource argument option when authorizing'); const authMethodOpts = authOpts; const keySeed = randomBytes(32); const didKey = await createDIDKey(keySeed); authMethodOpts.uri = didKey.id; if (authOpts.expiresInSecs) { const exp = new Date(Date.now() + authOpts.expiresInSecs * 1000); authMethodOpts.expirationTime = exp.toISOString(); } const cacao = await authMethod(authOpts); const did = await createDIDCacao(didKey, cacao); return new DIDSession({ cacao, keySeed, did }); } static async initDID(didKey, cacao) { const didWithCap = didKey.withCapability(cacao); await didWithCap.authenticate(); return didWithCap; } /** * Get a session for the given accountId, if one exists, otherwise creates a new one. */ static async get(account, authMethod, authOpts = {}) { if (!authOpts.resources || authOpts.resources.length === 0) throw new Error('Required: resource argument option when authorizing'); const store = await SessionStore.create(); const result = await store.get(account) || {}; let { cacao, keypair } = result; if (cacao && keypair && cacaoContainsResources(cacao, authOpts.resources) && !isExpired(cacao.p.exp)) { const provider = new WebcryptoProvider(keypair); const did = new DID({ provider, resolver: KeyDidResolver.getResolver(), capability: cacao }); await did.authenticate(); const session = new DIDSession({ cacao, did }); return session; } // create a new DID instance using the WebcryptoProvider keypair = await generateP256KeyPair(); const provider = new WebcryptoProvider(keypair); const didKey = new DID({ provider, resolver: KeyDidResolver.getResolver() }); await didKey.authenticate(); const authMethodOpts = authOpts; authMethodOpts.uri = didKey.id; if (authOpts.expiresInSecs) { const exp = new Date(Date.now() + authOpts.expiresInSecs * 1000); authMethodOpts.expirationTime = exp.toISOString(); } cacao = await authMethod(authMethodOpts); const did = await createDIDCacao(didKey, cacao); await store.set(account, { cacao, keypair }); store.close(); return new DIDSession({ cacao, did }); } /** * Removes a session from storage for a given account (if created using `DIDSession.get`) */ static async remove(account) { const store = await SessionStore.create(); await store.remove(account); store.close(); } /** * Check if there is an active session for a given account. */ static async hasSessionFor(account, resources) { const store = await SessionStore.create(); const { cacao } = await store.get(account) || {}; store.close(); return cacao && cacaoContainsResources(cacao, resources) && !isExpired(cacao.p.exp); } /** * Get DID instance, if authorized */ get did() { return _class_private_field_get(this, _did); } /** * Serialize session into string, can store and initalize the same session again while valid */ serialize() { if (!_class_private_field_get(this, _keySeed)) throw new Error('Secure sessions cannot be serialized'); const session = { sessionKeySeed: bytesToBase64(_class_private_field_get(this, _keySeed)), cacao: _class_private_field_get(this, _cacao) }; return JSONToBase64url(session); } /** * Initialize a session from a serialized session string */ static async fromSession(session) { const { sessionKeySeed, cacao } = base64urlToJSON(session); const keySeed = base64ToBytes(sessionKeySeed); const didKey = await createDIDKey(keySeed); const did = await DIDSession.initDID(didKey, cacao); return new DIDSession({ cacao, keySeed, did }); } get hasSession() { return !!_class_private_field_get(this, _cacao) && !!_class_private_field_get(this, _did); } /** * Determine if a session is expired or not */ get isExpired() { return isExpired(_class_private_field_get(this, _cacao).p.exp); } /** * Number of seconds until a session expires */ get expireInSecs() { const expTime = _class_private_field_get(this, _cacao).p.exp; if (!expTime) throw new Error('Session does not expire') // Removed in future ; const timeDiff = Date.parse(expTime) - Date.now(); return timeDiff < 0 ? 0 : timeDiff / 1000; } /** * Get the list of resources a session is authorized for */ get authorizations() { return _class_private_field_get(this, _cacao)?.p.resources ?? []; } /** * Get the session CACAO */ get cacao() { return _class_private_field_get(this, _cacao); } /** * Determine if session is available and optionally if authorized for given resources */ isAuthorized(resources) { if (!this.hasSession || this.isExpired) return false; if (!resources) return true; return resources.every((val)=>this.authorizations.includes(val)); } /** DID string associated to the session instance. session.id == session.getDID().parent */ get id() { return _class_private_field_get(this, _did).parent; } constructor(params){ _class_private_field_init(this, _did, { writable: true, value: void 0 }); _class_private_field_init(this, _keySeed, { writable: true, value: void 0 }); _class_private_field_init(this, _cacao, { writable: true, value: void 0 }); _class_private_field_set(this, _keySeed, params.keySeed); _class_private_field_set(this, _cacao, params.cacao); _class_private_field_set(this, _did, params.did // Remove did init param if/when async didKey authorize is removed ); } }