@web5/agent
Version:
858 lines (748 loc) • 29.9 kB
text/typescript
import { Convert, logger, RequireOnly } from '@web5/common';
import {
Ed25519,
EdDsaAlgorithm,
JoseHeaderParams,
Jwk,
Sha256,
X25519,
CryptoUtils,
} from '@web5/crypto';
import { concatenateUrl } from './utils.js';
import { xchacha20poly1305 } from '@noble/ciphers/chacha';
import type { ConnectPermissionRequest } from './connect.js';
import { DidDocument, DidJwk, PortableDid, type BearerDid } from '@web5/dids';
import { DwnDataEncodedRecordsWriteMessage, DwnInterface, DwnPermissionScope, DwnProtocolDefinition } from './types/dwn.js';
import { AgentPermissionsApi } from './permissions-api.js';
import type { Web5Agent } from './types/agent.js';
import { isRecordPermissionScope } from './dwn-api.js';
import { DwnInterfaceName, DwnMethodName } from '@tbd54566975/dwn-sdk-js';
/**
* Sent to an OIDC server to authorize a client. Allows clients
* to securely send authorization request parameters directly to
* the server via POST. This avoids exposing sensitive data in URLs
* and ensures the server validates the request before user interaction.
*
* @see {@link https://www.rfc-editor.org/rfc/rfc9126.html | OAuth 2.0 Pushed Authorization Requests}
*/
export type PushedAuthRequest = {
/** The JWT which contains the {@link Web5ConnectAuthRequest} */
request: string;
};
/**
* Sent back by OIDC server in response to {@link PushedAuthRequest}
* The server generates a TTL and a unique request_uri. The request_uri can be shared
* with the Provider using a link or a QR code along with additional params
* to access the url and decrypt the payload.
*/
export type PushedAuthResponse = {
request_uri: string;
expires_in: number;
};
/**
* Used in decentralized apps. The SIOPv2 Auth Request is created by a client relying party (RP)
* often a web service or an app who wants to obtain information from a provider
* The contents of this are inserted into a JWT inside of the {@link PushedAuthRequest}.
* @see {@link https://github.com/TBD54566975/known-customer-credential | TBD OIDC Documentation for SIOPv2 }
*/
export type SIOPv2AuthRequest = {
/** The DID of the client (RP) */
client_id: string;
/** The scope of the access request (e.g., `openid profile`). */
scope: string;
/** The type of response desired (e.g. `id_token`) */
response_type: string;
/** the URL to which the Identity Provider will post the Authorization Response */
redirect_uri: string;
/** The URI to which the SIOPv2 Authorization Response will be sent (Tim's note: not used with encrypted request JWT)*/
response_uri?: string;
/**
* An opaque value used to maintain state between the request and the callback.
* Recommended for security to prevent CSRF attacks.
*/
state: string;
/**
* A string value used to associate a client session with an ID token to mitigate replay attacks.
* Recommended when requesting ID tokens.
*/
nonce: string;
/**
* The PKCE code challenge.
* Required if `code_challenge_method` is used. Enhances security for public clients (e.g., single-page apps,
* mobile apps) by requiring an additional verification step during token exchange.
*/
code_challenge?: string;
/** The method used for the PKCE challenge (typically `S256`). Must be present if `code_challenge` is included. */
code_challenge_method?: 'S256';
/**
* An ID token previously issued to the client, passed as a hint about the end-user’s current or past authenticated
* session with the client. Can streamline user experience if already logged in.
*/
id_token_hint?: string;
/** A hint to the authorization server about the login identifier the user might use. Useful for pre-filling login information. */
login_hint?: string;
/** Requested Authentication Context Class Reference values. Specifies the authentication context requirements. */
acr_values?: string;
/** When using a PAR for secure cross device flows we use a "form_post" rather than a "direct_post" */
response_mode: 'direct_post';
/** Used by PFI to request VCs as input to IDV process. If present, `response_type: "vp_token""` MUST also be present */
presentation_definition?: any;
/** A JSON object containing the Verifier metadata values (Tim's note: from TBD KCC Repo) */
client_metadata?: {
/** Array of strings, each a DID method supported for the subject of ID Token */
subject_syntax_types_supported: string[];
/** Human-readable string name of the client to be presented to the end-user during authorization */
client_name?: string;
/** URI of a web page providing information about the client */
client_uri?: string;
/** URI of an image logo for the client */
logo_uri?: string;
/** Array of strings representing ways to contact people responsible for this client, typically email addresses */
contacts?: string[];
/** URI that points to a terms of service document for the client */
tos_uri?: string;
/** URI that points to a privacy policy document */
policy_uri?: string;
};
};
/**
* An auth request that is compatible with both Web5 Connect and (hopefully, WIP) OIDC SIOPv2
* The contents of this are inserted into a JWT inside of the {@link PushedAuthRequest}.
*/
export type Web5ConnectAuthRequest = {
/** The user friendly name of the client/app to be displayed when prompting end-user with permission requests. */
displayName: string;
/** PermissionGrants that are to be sent to the provider */
permissionRequests: ConnectPermissionRequest[];
} & SIOPv2AuthRequest;
/** The fields for an OIDC SIOPv2 Auth Repsonse */
export type SIOPv2AuthResponse = {
/** Issuer MUST match the value of sub (Applicant's DID) */
iss: string;
/** Subject Identifier. A locally unique and never reassigned identifier
* within the Issuer for the End-User, which is intended to be consumed
* by the Client. */
sub: string;
/** Audience(s) that this ID Token is intended for. It MUST contain the
* OAuth 2.0 client_id of the Relying Party as an audience value. */
aud: string;
/** Time at which the JWT was issued. */
iat: number;
/** Expiration time on or after which the ID Token MUST NOT be accepted
* for processing. */
exp: number;
/** Time when the End-User authentication occurred. */
auth_time?: number;
/** b64url encoded nonce used to associate a Client session with an ID Token, and to
* mitigate replay attacks. */
nonce?: string;
/** Custom claims. */
[key: string]: any;
};
/** An auth response that is compatible with both Web5 Connect and (hopefully, WIP) OIDC SIOPv2 */
export type Web5ConnectAuthResponse = {
delegateGrants: DwnDataEncodedRecordsWriteMessage[];
delegatePortableDid: PortableDid;
} & SIOPv2AuthResponse;
/** Represents the different OIDC endpoint types.
* 1. `pushedAuthorizationRequest`: client sends {@link PushedAuthRequest} receives {@link PushedAuthResponse}
* 2. `authorize`: provider gets the {@link Web5ConnectAuthRequest} JWT that was stored by the PAR
* 3. `callback`: provider sends {@link Web5ConnectAuthResponse} to this endpoint
* 4. `token`: client gets {@link Web5ConnectAuthResponse} from this endpoint
*/
type OidcEndpoint =
| 'pushedAuthorizationRequest'
| 'authorize'
| 'callback'
| 'token';
/**
* Gets the correct OIDC endpoint out of the {@link OidcEndpoint} options provided.
* Handles a trailing slash on baseURL
*
* @param {Object} options the options object
* @param {string} options.baseURL for example `http://foo.com/connect/
* @param {OidcEndpoint} options.endpoint the OIDC endpoint desired
* @param {string} options.authParam this is the unique id which must be provided when getting the `authorize` endpoint
* @param {string} options.tokenParam this is the random state as b64url which must be provided with the `token` endpoint
*/
function buildOidcUrl({
baseURL,
endpoint,
authParam,
tokenParam,
}: {
baseURL: string;
endpoint: OidcEndpoint;
authParam?: string;
tokenParam?: string;
}) {
switch (endpoint) {
/** 1. client sends {@link PushedAuthRequest} & client receives {@link PushedAuthResponse} */
case 'pushedAuthorizationRequest':
return concatenateUrl(baseURL, 'par');
/** 2. provider gets {@link Web5ConnectAuthRequest} */
case 'authorize':
if (!authParam)
throw new Error(
`authParam must be providied when building a token URL`
);
return concatenateUrl(baseURL, `authorize/${authParam}.jwt`);
/** 3. provider sends {@link Web5ConnectAuthResponse} */
case 'callback':
return concatenateUrl(baseURL, `callback`);
/** 4. client gets {@link Web5ConnectAuthResponse */
case 'token':
if (!tokenParam)
throw new Error(
`tokenParam must be providied when building a token URL`
);
return concatenateUrl(baseURL, `token/${tokenParam}.jwt`);
// TODO: metadata endpoints?
default:
throw new Error(`No matches for endpoint specified: ${endpoint}`);
}
}
/**
* Generates a cryptographically random "code challenge" in
* accordance with the RFC 7636 PKCE specification.
*
* @see {@link https://datatracker.ietf.org/doc/html/rfc7636#section-4.2 | RFC 7636 }
*/
async function generateCodeChallenge() {
const codeVerifierBytes = CryptoUtils.randomBytes(32);
const codeChallengeBytes = await Sha256.digest({ data: codeVerifierBytes });
const codeChallengeBase64Url =
Convert.uint8Array(codeChallengeBytes).toBase64Url();
return { codeChallengeBytes, codeChallengeBase64Url };
}
/** Client creates the {@link Web5ConnectAuthRequest} */
async function createAuthRequest(
options: RequireOnly<
Web5ConnectAuthRequest,
'client_id' | 'scope' | 'redirect_uri' | 'permissionRequests' | 'displayName'
>
) {
// Generate a random state value to associate the authorization request with the response.
const stateBytes = CryptoUtils.randomBytes(16);
// Generate a random nonce value to associate the ID Token with the authorization request.
const nonceBytes = CryptoUtils.randomBytes(16);
const requestObject: Web5ConnectAuthRequest = {
...options,
nonce : Convert.uint8Array(nonceBytes).toBase64Url(),
response_type : 'id_token',
response_mode : 'direct_post',
state : Convert.uint8Array(stateBytes).toBase64Url(),
client_metadata : {
subject_syntax_types_supported: ['did:dht', 'did:jwk'],
},
};
return requestObject;
}
/** Encrypts the auth request with the key which will be passed through QR code */
async function encryptAuthRequest({
jwt,
encryptionKey,
}: {
jwt: string;
encryptionKey: Uint8Array;
}) {
const protectedHeader = {
alg : 'dir',
cty : 'JWT',
enc : 'XC20P',
typ : 'JWT',
};
const nonce = CryptoUtils.randomBytes(24);
const additionalData = Convert.object(protectedHeader).toUint8Array();
const jwtBytes = Convert.string(jwt).toUint8Array();
const chacha = xchacha20poly1305(encryptionKey, nonce, additionalData);
const ciphertextAndTag = chacha.encrypt(jwtBytes);
/** The cipher output concatenates the encrypted data and tag
* so we need to extract the values for use in the JWE. */
const ciphertext = ciphertextAndTag.subarray(0, -16);
const authenticationTag = ciphertextAndTag.subarray(-16);
const compactJwe = [
Convert.object(protectedHeader).toBase64Url(),
'', // Empty string since there is no wrapped key.
Convert.uint8Array(nonce).toBase64Url(),
Convert.uint8Array(ciphertext).toBase64Url(),
Convert.uint8Array(authenticationTag).toBase64Url(),
].join('.');
return compactJwe;
}
/** Create a response object compatible with Web5 Connect and OIDC SIOPv2 */
async function createResponseObject(
options: RequireOnly<
Web5ConnectAuthResponse,
'iss' | 'sub' | 'aud' | 'delegateGrants' | 'delegatePortableDid'
>
) {
const currentTimeInSeconds = Math.floor(Date.now() / 1000);
const responseObject: Web5ConnectAuthResponse = {
...options,
iat : currentTimeInSeconds,
exp : currentTimeInSeconds + 600, // Expires in 10 minutes.
};
return responseObject;
}
/** sign an object and transform it into a jwt using a did */
async function signJwt({
did,
data,
}: {
did: BearerDid;
data: Record<string, unknown>;
}) {
const header = Convert.object({
alg : 'EdDSA',
kid : did.document.verificationMethod![0].id,
typ : 'JWT',
}).toBase64Url();
const payload = Convert.object(data).toBase64Url();
// signs using ed25519 EdDSA
const signer = await did.getSigner();
const signature = await signer.sign({
data: Convert.string(`${header}.${payload}`).toUint8Array(),
});
const signatureBase64Url = Convert.uint8Array(signature).toBase64Url();
const jwt = `${header}.${payload}.${signatureBase64Url}`;
return jwt;
}
/** Take the decrypted JWT and verify it was signed by its public DID. Return parsed object. */
async function verifyJwt({ jwt }: { jwt: string }) {
const [headerB64U, payloadB64U, signatureB64U] = jwt.split('.');
// Convert the header back to a JOSE object and verify that the 'kid' header value is present.
const header: JoseHeaderParams = Convert.base64Url(headerB64U).toObject();
if (!header.kid)
throw new Error(
`OIDC: Object could not be verified due to missing 'kid' header value.`
);
// Resolve the Client DID document.
const { didDocument } = await DidJwk.resolve(header.kid.split('#')[0]);
if (!didDocument)
throw new Error(
'OIDC: Object could not be verified due to Client DID resolution issue.'
);
// Get the public key used to sign the Object from the DID document.
const { publicKeyJwk } =
didDocument.verificationMethod?.find((method: any) => {
return method.id === header.kid;
}) ?? {};
if (!publicKeyJwk)
throw new Error(
'OIDC: Object could not be verified due to missing public key in DID document.'
);
const EdDsa = new EdDsaAlgorithm();
const isValid = await EdDsa.verify({
key : publicKeyJwk,
signature : Convert.base64Url(signatureB64U).toUint8Array(),
data : Convert.string(`${headerB64U}.${payloadB64U}`).toUint8Array(),
});
if (!isValid)
throw new Error(
'OIDC: Object failed verification due to invalid signature.'
);
const object = Convert.base64Url(payloadB64U).toObject();
return object;
}
/**
* Fetches the {@Web5ConnectAuthRequest} from the authorize endpoint and decrypts it
* using the encryption key passed via QR code.
*/
const getAuthRequest = async (request_uri: string, encryption_key: string) => {
const authRequest = await fetch(request_uri);
const jwe = await authRequest.text();
const jwt = decryptAuthRequest({
jwe,
encryption_key,
});
const web5ConnectAuthRequest = (await verifyJwt({
jwt,
})) as Web5ConnectAuthRequest;
return web5ConnectAuthRequest;
};
/** Take the encrypted JWE, decrypt using the code challenge and return a JWT string which will need to be verified */
function decryptAuthRequest({
jwe,
encryption_key,
}: {
jwe: string;
encryption_key: string;
}) {
const [
protectedHeaderB64U,
,
nonceB64U,
ciphertextB64U,
authenticationTagB64U,
] = jwe.split('.');
const encryptionKeyBytes = Convert.base64Url(encryption_key).toUint8Array();
const protectedHeader = Convert.base64Url(protectedHeaderB64U).toUint8Array();
const additionalData = protectedHeader;
const nonce = Convert.base64Url(nonceB64U).toUint8Array();
const ciphertext = Convert.base64Url(ciphertextB64U).toUint8Array();
const authenticationTag = Convert.base64Url(
authenticationTagB64U
).toUint8Array();
// The cipher expects the encrypted data and tag to be concatenated.
const ciphertextAndTag = new Uint8Array([
...ciphertext,
...authenticationTag,
]);
const chacha = xchacha20poly1305(encryptionKeyBytes, nonce, additionalData);
const decryptedJwtBytes = chacha.decrypt(ciphertextAndTag);
const jwt = Convert.uint8Array(decryptedJwtBytes).toString();
return jwt;
}
/**
* The client uses to decrypt the jwe obtained from the auth server which contains
* the {@link Web5ConnectAuthResponse} that was sent by the provider to the auth server.
*
* @async
* @param {BearerDid} clientDid - The did that was initially used by the client for ECDH at connect init.
* @param {string} jwe - The encrypted data as a jwe.
* @param {string} pin - The pin that was obtained from the user.
*/
async function decryptAuthResponse(
clientDid: BearerDid,
jwe: string,
pin: string
) {
const [
protectedHeaderB64U,
,
nonceB64U,
ciphertextB64U,
authenticationTagB64U,
] = jwe.split('.');
// get the delegatedid public key from the header
const header = Convert.base64Url(protectedHeaderB64U).toObject() as Jwk;
const delegateResolvedDid = await DidJwk.resolve(header.kid!.split('#')[0]);
// derive ECDH shared key using the provider's public key and our clientDid private key
const sharedKey = await Oidc.deriveSharedKey(
clientDid,
delegateResolvedDid.didDocument!
);
// add the pin to the AAD
const additionalData = { ...header, pin: pin };
const AAD = Convert.object(additionalData).toUint8Array();
const nonce = Convert.base64Url(nonceB64U).toUint8Array();
const ciphertext = Convert.base64Url(ciphertextB64U).toUint8Array();
const authenticationTag = Convert.base64Url(
authenticationTagB64U
).toUint8Array();
// The cipher expects the encrypted data and tag to be concatenated.
const ciphertextAndTag = new Uint8Array([
...ciphertext,
...authenticationTag,
]);
// decrypt using the sharedKey
const chacha = xchacha20poly1305(sharedKey, nonce, AAD);
const decryptedJwtBytes = chacha.decrypt(ciphertextAndTag);
const jwt = Convert.uint8Array(decryptedJwtBytes).toString();
return jwt;
}
/** Derives a shared ECDH private key in order to encrypt the {@link Web5ConnectAuthResponse} */
async function deriveSharedKey(
privateKeyDid: BearerDid,
publicKeyDid: DidDocument
) {
const privatePortableDid = await privateKeyDid.export();
const publicJwk = publicKeyDid.verificationMethod?.[0].publicKeyJwk!;
const privateJwk = privatePortableDid.privateKeys?.[0]!;
publicJwk.alg = 'EdDSA';
const publicX25519 = await Ed25519.convertPublicKeyToX25519({
publicKey: publicJwk,
});
const privateX25519 = await Ed25519.convertPrivateKeyToX25519({
privateKey: privateJwk,
});
const sharedKey = await X25519.sharedSecret({
privateKeyA : privateX25519,
publicKeyB : publicX25519,
});
const derivedKey = await crypto.subtle.importKey(
'raw',
sharedKey,
{ name: 'HKDF' },
false,
['deriveBits']
);
const derivedKeyBits = await crypto.subtle.deriveBits(
{
name : 'HKDF',
hash : 'SHA-256',
info : new Uint8Array(),
salt : new Uint8Array(),
},
derivedKey,
256
);
const sharedEncryptionKey = new Uint8Array(derivedKeyBits);
return sharedEncryptionKey;
}
/**
* Encrypts the auth response jwt. Requires a randomPin is added to the AAD of the
* encryption algorithm in order to prevent man in the middle and eavesdropping attacks.
* The keyid of the delegate did is used to pass the public key to the client in order
* for the client to derive the shared ECDH private key.
*/
function encryptAuthResponse({
jwt,
encryptionKey,
delegateDidKeyId,
randomPin,
}: {
jwt: string;
encryptionKey: Uint8Array;
delegateDidKeyId: string;
randomPin: string;
}) {
const protectedHeader = {
alg : 'dir',
cty : 'JWT',
enc : 'XC20P',
typ : 'JWT',
kid : delegateDidKeyId,
};
const nonce = CryptoUtils.randomBytes(24);
const additionalData = Convert.object({
...protectedHeader,
pin: randomPin,
}).toUint8Array();
const jwtBytes = Convert.string(jwt).toUint8Array();
const chacha = xchacha20poly1305(encryptionKey, nonce, additionalData);
const ciphertextAndTag = chacha.encrypt(jwtBytes);
/** The cipher output concatenates the encrypted data and tag
* so we need to extract the values for use in the JWE. */
const ciphertext = ciphertextAndTag.subarray(0, -16);
const authenticationTag = ciphertextAndTag.subarray(-16);
const compactJwe = [
Convert.object(protectedHeader).toBase64Url(),
'', // Empty string since there is no wrapped key.
Convert.uint8Array(nonce).toBase64Url(),
Convert.uint8Array(ciphertext).toBase64Url(),
Convert.uint8Array(authenticationTag).toBase64Url(),
].join('.');
return compactJwe;
}
function shouldUseDelegatePermission(scope: DwnPermissionScope): boolean {
// Currently all record permissions are treated as delegated permissions
// In the future only methods that modify state will be delegated and the rest will be normal permissions
if (isRecordPermissionScope(scope)) {
return true;
} else if (scope.interface === DwnInterfaceName.Protocols && scope.method === DwnMethodName.Configure) {
// ProtocolConfigure messages are also delegated, as they modify state
return true;
}
// All other permissions are not treated as delegated
return false;
}
/**
* Creates the permission grants that assign to the selectedDid the level of
* permissions that the web app requested in the {@link Web5ConnectAuthRequest}
*/
async function createPermissionGrants(
selectedDid: string,
delegateBearerDid: BearerDid,
agent: Web5Agent,
scopes: DwnPermissionScope[],
) {
const permissionsApi = new AgentPermissionsApi({ agent });
// TODO: cleanup all grants if one fails by deleting them from the DWN: https://github.com/TBD54566975/web5-js/issues/849
logger.log(`Creating permission grants for ${scopes.length} scopes given...`);
const permissionGrants = await Promise.all(
scopes.map((scope) => {
// check if the scope is a records permission scope, or a protocol configure scope, if so it should use a delegated permission.
const delegated = shouldUseDelegatePermission(scope);
return permissionsApi.createGrant({
delegated,
store : true,
grantedTo : delegateBearerDid.uri,
scope,
dateExpires : '2040-06-25T16:09:16.693356Z', // TODO: make dateExpires optional
author : selectedDid,
});
})
);
logger.log(`Sending ${permissionGrants.length} permission grants to remote DWN...`);
const messagePromises = permissionGrants.map(async (grant) => {
// Quirk: we have to pull out encodedData out of the message the schema validator doesn't want it there
const { encodedData, ...rawMessage } = grant.message;
const data = Convert.base64Url(encodedData).toUint8Array();
const { reply } = await agent.sendDwnRequest({
author : selectedDid,
target : selectedDid,
messageType : DwnInterface.RecordsWrite,
dataStream : new Blob([data]),
rawMessage,
});
// check if the message was sent successfully, if the remote returns 409 the message may have come through already via sync
if (reply.status.code !== 202 && reply.status.code !== 409) {
logger.error(`Error sending RecordsWrite: ${reply.status.detail}`);
logger.error(`RecordsWrite message: ${rawMessage}`);
throw new Error(
`Could not send the message. Error details: ${reply.status.detail}`
);
}
return grant.message;
});
try {
const messages = await Promise.all(messagePromises);
return messages;
} catch (error) {
logger.error(`Error during batch-send of permission grants: ${error}`);
throw error;
}
}
/**
* Installs the protocol required by the Client on the Provider if it doesn't already exist.
*/
async function prepareProtocol(
selectedDid: string,
agent: Web5Agent,
protocolDefinition: DwnProtocolDefinition
): Promise<void> {
const queryMessage = await agent.processDwnRequest({
author : selectedDid,
messageType : DwnInterface.ProtocolsQuery,
target : selectedDid,
messageParams : { filter: { protocol: protocolDefinition.protocol } },
});
if ( queryMessage.reply.status.code !== 200) {
// if the query failed, throw an error
throw new Error(
`Could not fetch protocol: ${queryMessage.reply.status.detail}`
);
} else if (queryMessage.reply.entries === undefined || queryMessage.reply.entries.length === 0) {
logger.log(`Protocol does not exist, creating: ${protocolDefinition.protocol}`);
// send the protocol definition to the remote DWN first, if it passes we can process it locally
const { reply: sendReply, message: configureMessage } = await agent.sendDwnRequest({
author : selectedDid,
target : selectedDid,
messageType : DwnInterface.ProtocolsConfigure,
messageParams : { definition: protocolDefinition },
});
// check if the message was sent successfully, if the remote returns 409 the message may have come through already via sync
if (sendReply.status.code !== 202 && sendReply.status.code !== 409) {
throw new Error(`Could not send protocol: ${sendReply.status.detail}`);
}
// process the protocol locally, we don't have to check if it exists as this is just a convenience over waiting for sync.
await agent.processDwnRequest({
author : selectedDid,
target : selectedDid,
messageType : DwnInterface.ProtocolsConfigure,
rawMessage : configureMessage
});
} else {
logger.log(`Protocol already exists: ${protocolDefinition.protocol}`);
// the protocol already exists, let's make sure it exists on the remote DWN as the requesting app will need it
const configureMessage = queryMessage.reply.entries![0];
const { reply: sendReply } = await agent.sendDwnRequest({
author : selectedDid,
target : selectedDid,
messageType : DwnInterface.ProtocolsConfigure,
rawMessage : configureMessage,
});
if (sendReply.status.code !== 202 && sendReply.status.code !== 409) {
throw new Error(`Could not send protocol: ${sendReply.status.detail}`);
}
}
}
/**
* Creates a delegate did which the web app will use as its future indentity.
* Assigns to that DID the level of permissions that the web app requested in
* the {@link Web5ConnectAuthRequest}. Encrypts via ECDH key that the web app
* will have access to because the web app has the public key which it provided
* in the {@link Web5ConnectAuthRequest}. Then sends the ciphertext of this
* {@link Web5ConnectAuthResponse} to the callback endpoint. Which the
* web app will need to retrieve from the token endpoint and decrypt with the pin to access.
*/
async function submitAuthResponse(
selectedDid: string,
authRequest: Web5ConnectAuthRequest,
randomPin: string,
agent: Web5Agent
) {
const delegateBearerDid = await DidJwk.create();
const delegatePortableDid = await delegateBearerDid.export();
// TODO: roll back permissions and protocol configurations if an error occurs. Need a way to delete protocols to achieve this.
const delegateGrantPromises = authRequest.permissionRequests.map(
async (permissionRequest) => {
const { protocolDefinition, permissionScopes } = permissionRequest;
// We validate that all permission scopes match the protocol uri of the protocol definition they are provided with.
const grantsMatchProtocolUri = permissionScopes.every(scope => 'protocol' in scope && scope.protocol === protocolDefinition.protocol);
if (!grantsMatchProtocolUri) {
throw new Error('All permission scopes must match the protocol uri they are provided with.');
}
await prepareProtocol(selectedDid, agent, protocolDefinition);
const permissionGrants = await Oidc.createPermissionGrants(
selectedDid,
delegateBearerDid,
agent,
permissionScopes
);
return permissionGrants;
}
);
const delegateGrants = (await Promise.all(delegateGrantPromises)).flat();
logger.log('Generating auth response object...');
const responseObject = await Oidc.createResponseObject({
//* the IDP's did that was selected to be connected
iss : selectedDid,
//* the client's new identity
sub : delegateBearerDid.uri,
//* the client's temporary ephemeral did used for connect
aud : authRequest.client_id,
//* the nonce of the original auth request
nonce : authRequest.nonce,
delegateGrants,
delegatePortableDid,
});
// Sign the Response Object using the ephemeral DID's signing key.
logger.log('Signing auth response object...');
const responseObjectJwt = await Oidc.signJwt({
did : delegateBearerDid,
data : responseObject,
});
const clientDid = await DidJwk.resolve(authRequest.client_id);
const sharedKey = await Oidc.deriveSharedKey(
delegateBearerDid,
clientDid?.didDocument!
);
logger.log('Encrypting auth response object...');
const encryptedResponse = Oidc.encryptAuthResponse({
jwt : responseObjectJwt!,
encryptionKey : sharedKey,
delegateDidKeyId : delegateBearerDid.document.verificationMethod![0].id,
randomPin,
});
const formEncodedRequest = new URLSearchParams({
id_token : encryptedResponse,
state : authRequest.state,
}).toString();
logger.log(`Sending auth response object to Web5 Connect server: ${authRequest.redirect_uri}`);
await fetch(authRequest.redirect_uri, {
body : formEncodedRequest,
method : 'POST',
headers : {
'Content-Type': 'application/x-www-form-urlencoded',
},
});
}
export const Oidc = {
createAuthRequest,
encryptAuthRequest,
getAuthRequest,
decryptAuthRequest,
createPermissionGrants,
createResponseObject,
encryptAuthResponse,
decryptAuthResponse,
deriveSharedKey,
signJwt,
verifyJwt,
buildOidcUrl,
generateCodeChallenge,
submitAuthResponse,
};