UNPKG

@microsoft/useragent-sdk

Version:

SDK for building decentralized identity wallets and enterprise agents.

391 lines (350 loc) 15.5 kB
/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import Identifier from '../Identifier'; import JwsToken from '../crypto/protocols/jose/jws/JwsToken'; import UserAgentOptions from '../UserAgentOptions'; import IResolver from '../resolvers/IResolver'; import CryptoFactory from '../crypto/plugin/CryptoFactory'; import OIDCAuthenticationRequest from '../crypto/protocols/did/requests/OIDCAuthenticationRequest'; import OIDCAuthenticationResponse from '../crypto/protocols/did/responses/OIDCAuthenticationResponse'; import UserAgentError from '../UserAgentError'; import PublicKey from '../crypto/keys/PublicKey'; import IManifest from './oidc/requests/IManifest'; import IRequestPrompt, { IPermissionRequestPrompt } from './oidc/requests/IRequestPrompt'; import IScopeDefinition, { IScopeRequest } from './oidc/requests/IScopeDefinition'; import nodeFetch from 'node-fetch'; import { URL } from 'url'; import { PERMISSION_GRANT_CONTEXT, PERMISSION_GRANT_TYPE } from '../hubSession/objects/IPermissionGrant'; import Permissions from '../hubInterfaces/Permissions'; import { HubInterfaceType, CommitStrategyType } from '../hubInterfaces/HubInterface'; import IResponseReceipt, { IPermissionReceipt } from './oidc/response/IResponseReceipt'; import { HubWriteResponse, CryptoOptions } from '..'; /** * OIDC optional parameters */ export interface OIDCRequestOptions { /** * claims to request be included in the response * Should follow https://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter * @throws Claims are a future feature and not yet supported by the user agent. */ claimRequests?: {[claimName: string]: any}, /** * State to include in the response */ state?: string, /** * Requester manifest for UI presentation */ manifest?: IManifest, /** * Additional URLs of @see IScopeDefinition to include */ scopes?: IScopeRequest[] } /** * Class for creating a User Agent Session for sending and verifying * Authentication Requests and Responses. */ export default class UserAgentSession { private sender: Identifier; private resolver: IResolver; private keyReference: string private options: UserAgentOptions; private cryptoFactory: CryptoFactory; private permissions: Permissions | undefined; constructor (sender: Identifier, keyReference: string, resolver: IResolver) { this.sender = sender; this.resolver = resolver; this.keyReference = keyReference; this.options = <UserAgentOptions>sender.options; this.cryptoFactory = this.options.cryptoFactory; } /** * Sign a User Agent Request. * @param redirectUrl url that recipient should send response back to. * @param nonce nonce that will come back in response. * @param options Open ID Connect optional parameters */ public async signRequest(redirectUrl: string, nonce: string, options?: OIDCRequestOptions): Promise<string> { const request: Partial<OIDCAuthenticationRequest> = { iss: this.sender.id, response_type: 'id_token', response_mode: 'form_post', client_id: redirectUrl, scope: 'openid did_authn', nonce, registration: {id_token_signed_response_alg: [ 'EdDSA' ]} }; if (options) { if (options.scopes) { const additionalScopes = options.scopes.map((scope) => Buffer.from(JSON.stringify(scope)).toString('base64')).join(' '); request.scope = `openid did_authn ${additionalScopes}`; } if (options.state) { Object.assign(request, {state: options.state}); } if (options.claimRequests) { // throw new Error('UserAgent does not currently support open ID Connect claims'); Object.assign(request, {claims: {id_token: options.claimRequests}}); } /** * adds manifest to registration parameter (backwards compatible for Identiverse demo). * else adds the id_token_signed_response_alg parameter. */ if (options.manifest) { Object.assign(request, {registration: JSON.stringify(options.manifest)}); } } return this.sender.sign(request, this.keyReference); } /** * Send an Open ID Connect User Agent Response. * @param request Open ID Connect request to respond to. * @param claims Future Feature: any claims the request asked for. * @param grants any permissionGrants approved by the user. * @throws Throws if claims are included */ public async sendResponse (request: OIDCAuthenticationRequest, grants?: IPermissionRequestPrompt[], claims?: any): Promise<any> { // check if sender has keystore if (!this.sender.options || !this.sender.options.keyStore) { throw new UserAgentError(`No KeyStore specified for '${this.sender.id}`); } // get public key of key referenced by keyReference in keystore const publicKey = (await this.sender.options.keyStore.get(this.keyReference, true)).getKey<PublicKey>(); // milliseconds to seconds const milliseconds = 1000; const expirationTimeOffsetInMinutes = 5; const expiration = new Date(Date.now() + milliseconds * 60 * expirationTimeOffsetInMinutes); // 5 minutes from now const exp = Math.floor(expiration.getTime() / milliseconds); const iat = Math.floor(Date.now() / milliseconds); // ms to seconds // create OIDC response object let response: OIDCAuthenticationResponse = { iss: 'https://self-issued.me', sub: await PublicKey.getThumbprint(publicKey), aud: request.client_id, nonce: request.nonce, did_comm: { did: this.sender.id }, sub_jwk: publicKey, iat, exp }; // add requested claims to response if (claims) { response = Object.assign(response, claims); } // response = Object.assign(response, claims); let receipt: IResponseReceipt = { credentials: [], permissionGrants: [] }; if (grants) { // Send hub permission objects if (!this.permissions) { this.permissions = new Permissions({ hubOwner: this.sender, clientIdentifier: this.sender, recipientsPublicKeys: undefined, context: PERMISSION_GRANT_CONTEXT, type: PERMISSION_GRANT_TYPE, hubInterface: HubInterfaceType.Permissions, commitStrategy: CommitStrategyType.Basic, cryptoOptions: new CryptoOptions(), hubProtectionStrategy: undefined }); } for (let i = 0; i < grants.length; i++) { const permissionBundle = grants[i]; const permissionReceipt: IPermissionReceipt = { owner: this.sender.id, grantee: request.iss, definition: { name: permissionBundle.name, description: permissionBundle.description, iconUrl: permissionBundle.iconUrl }, objects: [], }; for (let j = 0; j < permissionBundle.grants.length; j++) { let grant = permissionBundle.grants[j]; grant.owner = this.sender.id; grant.grantee = request.iss; const grantWriteResponse: HubWriteResponse = await this.permissions.addObject(grants[i]); permissionReceipt.objects.push(grantWriteResponse.getRevisions()[0]); } receipt.permissionGrants.push(permissionReceipt); } } // sign the response const jws = await this.sender.sign(response, this.keyReference); // create response body string let responseBody = `id_token=${jws}`; if (request.state) { responseBody = `${responseBody}&state=${request.state}`; } const oidcResponse = await nodeFetch(request.client_id, { method: 'POST', body: responseBody, headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': responseBody.length.toString() }, }); if (oidcResponse.status !== 204) { throw new Error(`OpenID Connect response failed to send: ${oidcResponse.text()}`); } if (!claims && !grants) { return oidcResponse; } return receipt; } /** * Verify a request was signed and sent by Identifier. * @param jws Signed Payload */ public async verify(jws: string): Promise<any> { // get identifier id from key id in header. const token : JwsToken = await JwsToken.deserialize(jws, {cryptoFactory: this.cryptoFactory}); const payload = JSON.parse(token.payload.toString()); /** * If iss parameter is 'https://selfissued.me', payload is an OIDC auth request * and did is in the did parameter. Else the payload is a OIDC auth response, * so the did is in the iss parameter. If either parameter is undefined, * the payload is not formatted properly, so throw an error. */ const issuerIdentifier = payload.iss === 'https://self-issued.me' ? payload.did_comm.did : payload.iss; if (!issuerIdentifier) { throw new UserAgentError('Unable to identify issuer of the token.'); } // verify jws and return payload. const identifier = new Identifier(issuerIdentifier, this.options); const document = await identifier.getDocument(); const publicKeysFromDocument = document.getPublicKeysFromDocument(); if (token.signatures.length < 0) { throw new UserAgentError('No signature included'); } let keyMatches: RegExpMatchArray | null = null; const keyIdRegex = /([^#]*)#?(.+$)/; if (token.signatures[0].protected && (token.signatures[0].protected).has('kid')) { const keyIdentifier: string = (token.signatures[0].protected).get('kid'); keyMatches = keyIdentifier.match(keyIdRegex); } else if (token.signatures[0].header && (token.signatures[0].header).has('kid')) { const keyIdentifier: string = (token.signatures[0].header).get('kid'); keyMatches = keyIdentifier.match(keyIdRegex); } if (keyMatches === null) { throw new UserAgentError('Cannot locate keyID'); } if (keyMatches[1].length > 0 && keyMatches[1] !== identifier.id) { throw new UserAgentError('Issuer signer does not match issuer'); } const keyId = keyMatches[2]; const matchingPublicKeys = publicKeysFromDocument.filter((publicKey) => { return publicKey.kid && publicKey.kid.endsWith(keyId); }); if (!await token.verify(matchingPublicKeys)) { throw new UserAgentError('Invalid signature'); } return payload; } /** * Verify an Open ID Connect response was signed and sent by response.did. * @param responseJws Signed response token */ public async verifyResponse(responseJws: string): Promise<OIDCAuthenticationResponse> { return <Promise<OIDCAuthenticationResponse>> this.verify(responseJws); } /** * Verify an Open ID Connect request was signed and sent by Identifier, and return all contents * @param request Signed request token */ public async verifyAndHydrateRequest(requestJws: string): Promise<IRequestPrompt> { const request: OIDCAuthenticationRequest = await this.verify(requestJws); const prompt: IRequestPrompt = Object.assign({}, request); // if there are credentials requested, include them. if (request.claims && 'credential' in request.claims.id_token) { prompt.credentialsRequested = Object.keys(request.claims.id_token.credential); } // if there are scopes requested include them. let scopes = request.scope.split(' '); if (scopes.length > 2) { scopes = scopes.filter((scope) => scope !== 'openid' && scope !== 'did_authn'); prompt.identityHubPermissionsRequested = []; // Not forEach to allow proper async await for (let i = 0; i < scopes.length; i++) { prompt.identityHubPermissionsRequested.push(...(await this.parseScopeValue(request.iss, scopes[i]))); } } // if there are credentials minted, include them. // const MICROSOFT_BLUE = '#0078d4'; // if there is a registration, pull requester information if (request.registration) { let manifest: IManifest; if (typeof(request.registration) === 'string') { manifest = <IManifest> JSON.parse(request.registration); if (manifest.client_name) { prompt.name = manifest.client_name; prompt.logoUrl = manifest.logo_uri; prompt.dataUsePolicy = manifest.policy_uri; prompt.homepage = manifest.client_uri; prompt.termsOfService = manifest.tos_uri; } } else { manifest = request.registration; } } // any additional scraping we can do from the request metadata itself if (request.client_id.startsWith('http')) { // the redirect_uri is over http(s) const redirectUrl = new URL(request.client_id); prompt.host = redirectUrl.host; } return prompt; } /** * Parses a encoded scope value and constructs IPermissionRequestPrompts * @param requester the DID of the requester for this permission * @param encodedScope The Encoded scope value * @returns IPermissionRequestPrompt(s) corresponding to the scopes requested */ private async parseScopeValue(requester: string, encodedScope: string): Promise<IPermissionRequestPrompt[]> { const prompts: IPermissionRequestPrompt[] = []; const scope: IScopeRequest = JSON.parse(Buffer.from(encodedScope, 'base64').toString()); const scopeDefinitionUris = Object.keys(scope); for (let j = 0; j < scopeDefinitionUris.length; j++) { const scopeDefinitionUri = scopeDefinitionUris[j]; const scopeModifiers = scope[scopeDefinitionUri]; // Performance Opprotunity: opprotunity to parallelize const response = await nodeFetch(scopeDefinitionUri); if (response.status !== 200) { throw Error(`Failed to retrieve Scope ${scopeDefinitionUri}. ${await response.text()}`) } const scopeDefinition = <IScopeDefinition> JSON.parse(await response.text()); prompts.push({ required: scopeModifiers && scopeModifiers.essential ? scopeModifiers.essential : false, name: scopeDefinition.resourceBundle.name, description: scopeDefinition.resourceBundle.description, iconUrl: scopeDefinition.resourceBundle.icon_uri, grants: scopeDefinition.access.map((grantRequest) => { const contextTypeRegex = /(^[^\/]+\/\/.*)\/(.*)$/i; const matches = grantRequest.resource_type.match(contextTypeRegex); if (!matches) { throw new Error(`Scope Definition ${scopeDefinitionUri} has invalid access resource_type ${grantRequest.resource_type}`); } return { owner: this.sender.id, grantee: requester, allow: grantRequest.allow, context: matches[1], type: matches[2] }; }), }); } return prompts; } }