did-session
Version:
Manage user DIDs in a web environment
394 lines (393 loc) • 14.8 kB
JavaScript
/**
* 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
);
}
}