@connectid-tools/rp-nodejs-sdk 4.0.4

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/config.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ import { RelyingPartyClientSdkConfig } from './types';
2
+ export declare const config: RelyingPartyClientSdkConfig;
package/config.js ADDED
@@ -0,0 +1,75 @@
1
+ export const config = {
2
+ data: {
3
+ // Set the signing Key Id based on what is contained in the JWKS
4
+ signing_kid: 'roHtgBlRFapqTHbc8EzXIIgO_bu5YHlEjx75vIcaxfE',
5
+ // The location of the signing certificate and key that are used for signing purposes
6
+ signing_key: './certs/signing.key',
7
+ signing_pem: './certs/signing.pem', // TODO not being used atm
8
+ // The location of the transport certificate and key that are used for mutual TLS
9
+ transport_key: './certs/transport.key',
10
+ transport_pem: './certs/transport.pem',
11
+ // The location of the root certificate for the trust authority
12
+ ca_pem: './certs/connectid-sandbox-ca.pem',
13
+ // This is the URL that this application is actually running on and using for callbacks (noting that multiple may be registered for the client)
14
+ application_redirect_uri: 'https://tpp.localhost/cb',
15
+ // The registry API endpoint that will list all participants with their auth server details
16
+ registry_participants_uri: 'https://data.directory.sandbox.connectid.com.au/participants',
17
+ // This will ensure that all participants are returned, regardless of their certification status
18
+ // and any `required_claims` or `required_participant_certifications` requirements.
19
+ // This should be set to false in a production environment, only enable for testing.
20
+ // If not provided, will default to false
21
+ include_uncertified_participants: false,
22
+ // Configuring `required_participant_certifications` will ensure that the participants returned are only those
23
+ // with the required certifications. Participants must have all specified certifications to be included.
24
+ // Note that if `include_uncertified_participants` is set to true, this will be ignored.
25
+ // As an example, if you require the IDPs to have TDIF certification as part of your use case, you
26
+ // can filter for: `[{ profileType: 'TDIF Accreditation', profileVariant: 'Identity Provider' }]
27
+ // required_participant_certifications: [
28
+ // { profileType: 'TDIF Accreditation', profileVariant: 'Identity Provider' }
29
+ // ],
30
+ // The list of claims that authorisation servers must support to be included in the list of participants
31
+ // If this is not provided, no filtering based on claims will be performed
32
+ // Note that if `include_uncertified_participants` is set to true, this will be ignored.
33
+ // required_claims: ['name', 'given_name', 'middle_name', 'family_name', 'phone_number', 'email', 'address', 'birthdate'],
34
+ // The application logging level (info - normal logging, debug - full request/response)
35
+ // This MUST not be set to debug in a production environment as it will log all personal data received
36
+ log_level: 'info',
37
+ // When running the OIDC FAPI compliance suite, it requires a call to user info after successfully decoding the
38
+ // response claims. If this is set to true, the SDK will automatically make the call.
39
+ enable_auto_compliance_verification: false,
40
+ // The purpose to be displayed to the consumer to indicate why their data is being requested to be shared
41
+ // Must be between 3 and 300 chars and not contain any of the following characters: <>(){}'\
42
+ purpose: 'verifying your identity',
43
+ cache_ttl: 600,
44
+ client: {
45
+ // Update with your client specific metadata. The client_id and organisation_id can be found in the registry.
46
+ client_id: 'https://rp.directory.sandbox.connectid.com.au/openid_relying_party/280518db-9807-4824-b080-324d94b45f6a',
47
+ organisation_id: 'ab837240-9618-4953-966e-90fd1fa63999',
48
+ jwks_uri: 'https://keystore.directory.sandbox.connectid.com.au/ab837240-9618-4953-966e-90fd1fa63999/280518db-9807-4824-b080-324d94b45f6a/application.jwks',
49
+ redirect_uris: ['https://demo.relyingpart.net/cb', 'https://tpp.localhost/cb'],
50
+ organisation_name: 'ConnectID Developer Tools Sample App',
51
+ organisation_number: 'ABN123123123',
52
+ software_description: 'App to demonstrate ConnectID end to end flows.',
53
+ // The following config is here for reference - you should not need to change any of it
54
+ application_type: 'web',
55
+ grant_types: ['client_credentials', 'authorization_code', 'implicit'],
56
+ id_token_signed_response_alg: 'PS256',
57
+ post_logout_redirect_uris: [],
58
+ require_auth_time: false,
59
+ response_types: ['code id_token', 'code'],
60
+ subject_type: 'public',
61
+ token_endpoint_auth_method: 'private_key_jwt',
62
+ token_endpoint_auth_signing_alg: 'PS256',
63
+ introspection_endpoint_auth_method: 'private_key_jwt',
64
+ revocation_endpoint_auth_method: 'private_key_jwt',
65
+ request_object_signing_alg: 'PS256',
66
+ require_signed_request_object: true,
67
+ require_pushed_authorization_requests: true,
68
+ authorization_signed_response_alg: 'PS256',
69
+ tls_client_certificate_bound_access_tokens: true,
70
+ backchannel_user_code_parameter: false,
71
+ scope: 'openid',
72
+ software_roles: ['RP-CORE'],
73
+ },
74
+ },
75
+ };
@@ -0,0 +1,12 @@
1
+ import { Participant, CertificationFilter } from '../types';
2
+ export default class ParticipantFilters {
3
+ removeOutOfDateCertifications(participants: Participant[], referenceDate: Date): Participant[];
4
+ removeUnofficialCertifications(participants: Participant[]): Participant[];
5
+ removeInactiveAuthServers(participants: Participant[]): Participant[];
6
+ removeParticipantsWithoutAuthServers(participants: Participant[]): Participant[];
7
+ filterAuthServersForSupportedClaims(participants: Participant[], claims: string[]): Participant[];
8
+ filterForRequiredCertifications(participants: Participant[], certifications: CertificationFilter[]): Participant[];
9
+ removeFallbackIdentityServiceProvider(participants: Participant[]): Participant[];
10
+ filterForFallbackIdentityServiceProviders(participants: Participant[]): Participant[];
11
+ private toDate;
12
+ }
@@ -0,0 +1,92 @@
1
+ // Set of filters to support filtering Participants and Authorisation Servers based on their status and capabilities.
2
+ export default class ParticipantFilters {
3
+ removeOutOfDateCertifications(participants, referenceDate) {
4
+ for (const participant of participants) {
5
+ for (const authServer of participant.AuthorisationServers) {
6
+ authServer.AuthorisationServerCertifications = authServer.AuthorisationServerCertifications.filter((certification) => {
7
+ return this.toDate(certification.CertificationStartDate) < referenceDate && this.toDate(certification.CertificationExpirationDate) > referenceDate;
8
+ });
9
+ }
10
+ }
11
+ return participants;
12
+ }
13
+ removeUnofficialCertifications(participants) {
14
+ for (const participant of participants) {
15
+ for (const authServer of participant.AuthorisationServers) {
16
+ authServer.AuthorisationServerCertifications = authServer.AuthorisationServerCertifications.filter((certification) => {
17
+ return certification.Status === 'Certified';
18
+ });
19
+ }
20
+ }
21
+ return participants;
22
+ }
23
+ // Remove any inactive authorisation servers that do not have a valid "Redirect:FAPI2 Adv. OP w/Private Key, PAR" certification
24
+ // The filtering assumes any out of date certifications have already been removed
25
+ removeInactiveAuthServers(participants) {
26
+ for (const participant of participants) {
27
+ participant.AuthorisationServers = participant.AuthorisationServers.filter((authServer) => {
28
+ return authServer.AuthorisationServerCertifications.some((certification) => {
29
+ return certification.ProfileType === 'Redirect' && certification.ProfileVariant === 'FAPI2 Adv. OP w/Private Key, PAR';
30
+ });
31
+ });
32
+ }
33
+ return participants;
34
+ }
35
+ removeParticipantsWithoutAuthServers(participants) {
36
+ return participants.filter((participant) => {
37
+ return participant.AuthorisationServers.length > 0;
38
+ });
39
+ }
40
+ filterAuthServersForSupportedClaims(participants, claims) {
41
+ const nameClaims = ['name', 'given_name', 'middle_name', 'family_name'];
42
+ const hasNameClaims = claims.find((claim) => nameClaims.includes(claim));
43
+ // All name claims (name, given_name, middle_name, family_name) are mapped to `name` in the authorisation server
44
+ // So if an authorisation server has any name claim, remove all name claims and leave only `name`
45
+ const formattedClaims = hasNameClaims
46
+ ? ['name', ...claims.filter((claim) => !nameClaims.includes(claim))]
47
+ : claims;
48
+ for (const participant of participants) {
49
+ participant.AuthorisationServers = participant.AuthorisationServers.filter((authorisationServer) => {
50
+ const authorisationServerClaims = authorisationServer.AuthorisationServerCertifications.filter(({ ProfileType }) => ProfileType === 'ConnectID Claims').map((cert) => cert.ProfileVariant);
51
+ return formattedClaims.every((claim) => authorisationServerClaims.includes(claim));
52
+ });
53
+ }
54
+ return participants;
55
+ }
56
+ filterForRequiredCertifications(participants, certifications) {
57
+ for (const certification of certifications) {
58
+ for (const participant of participants) {
59
+ participant.AuthorisationServers = participant.AuthorisationServers.filter((authServer) => {
60
+ return authServer.AuthorisationServerCertifications.some((serverCertification) => {
61
+ return (serverCertification.ProfileType === certification.profileType && serverCertification.ProfileVariant === certification.profileVariant);
62
+ });
63
+ });
64
+ }
65
+ }
66
+ return participants;
67
+ }
68
+ removeFallbackIdentityServiceProvider(participants) {
69
+ for (const participant of participants) {
70
+ participant.AuthorisationServers = participant.AuthorisationServers.filter((authServer) => {
71
+ return authServer.AuthorisationServerCertifications.every((certification) => {
72
+ return !(certification.ProfileType === 'ConnectID' && certification.ProfileVariant === 'Fallback Identity Service Provider');
73
+ });
74
+ });
75
+ }
76
+ return participants;
77
+ }
78
+ filterForFallbackIdentityServiceProviders(participants) {
79
+ for (const participant of participants) {
80
+ participant.AuthorisationServers = participant.AuthorisationServers.filter((authServer) => {
81
+ return authServer.AuthorisationServerCertifications.some((certification) => {
82
+ return certification.ProfileType === 'ConnectID' && certification.ProfileVariant === 'Fallback Identity Service Provider';
83
+ });
84
+ });
85
+ }
86
+ return participants;
87
+ }
88
+ toDate(dateString) {
89
+ var dateParts = dateString.split("/");
90
+ return new Date(+dateParts[2], +dateParts[1] - 1, +dateParts[0]);
91
+ }
92
+ }
package/logger.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare const getLogger: (logLevel?: "debug" | "info") => import("winston").Logger;
package/logger.js ADDED
@@ -0,0 +1,9 @@
1
+ import { createLogger, format, transports } from 'winston';
2
+ export const getLogger = (logLevel = 'info') => {
3
+ const logFormat = format.printf(({ level, message, timestamp }) => `${timestamp} ${level}: ${message}`);
4
+ return createLogger({
5
+ level: logLevel,
6
+ format: format.combine(format.colorize(), format.timestamp(), logFormat),
7
+ transports: [new transports.Console()],
8
+ });
9
+ };
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@connectid-tools/rp-nodejs-sdk",
3
+ "version": "4.0.4",
4
+ "description": "Digital Identity Relying Party Node SDK",
5
+ "main": "relying-party-client-sdk.js",
6
+ "types": "relying-party-client-sdk.d.ts",
7
+ "type": "module",
8
+ "scripts": {
9
+ "format": "npx prettier --write src",
10
+ "test": "node --import tsx --test src/tests/*.test.ts",
11
+ "test:watch": "node --watch --test --import tsx src/tests/*.test.ts",
12
+ "test:conformance": "node --import tsx --test src/conformance/conformance.test.ts",
13
+ "prebuild": "rm -rf lib",
14
+ "build": "tsc",
15
+ "postbuild": "cp package.json lib && cd lib && node ../node_modules/add-js-extension/dist/bin.js . --once && replace-in-files --string='${process.env.SDK_VERSION}' --replacement=$npm_package_version relying-party-client-sdk.js && cd .."
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/connectid-tools/rp-nodejs-sdk.git"
20
+ },
21
+ "keywords": [
22
+ "SDK",
23
+ "DigitalIdentity"
24
+ ],
25
+ "author": "ConnectID",
26
+ "bugs": {
27
+ "url": "https://github.com/connectid-tools/rp-nodejs-sample-app/issues"
28
+ },
29
+ "homepage": "https://github.com/connectid-tools/rp-nodejs-sample-app",
30
+ "dependencies": {
31
+ "https": "^1.0.0",
32
+ "node-jose": "^2.2.0",
33
+ "openid-client": "^5.7.1",
34
+ "winston": "^3.17.0"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^20.17.19",
38
+ "@types/openid-client": "^3.7.0",
39
+ "add-js-extension": "^1.0.4",
40
+ "eslint": "^9.20.1",
41
+ "prettier": "^3.5.1",
42
+ "replace-in-files-cli": "^2.2.0",
43
+ "tsx": "^4.19.3",
44
+ "typescript": "^5.7.3"
45
+ }
46
+ }
@@ -0,0 +1,37 @@
1
+ import { CallbackParamsType } from 'openid-client';
2
+ import { AuthorisationServer, ConsolidatedTokenSet, Participant, RelyingPartyClientSdkConfig } from './types';
3
+ export default class RelyingPartyClientSdk {
4
+ private readonly logger;
5
+ private config;
6
+ private readonly transportKey;
7
+ private readonly transportPem;
8
+ private readonly signingKey;
9
+ private readonly caPem;
10
+ private readonly purpose;
11
+ private participantFilters;
12
+ private readonly default_cache_ttl;
13
+ private cachedParticipantsExpiry;
14
+ private cachedParticipants;
15
+ constructor(config: RelyingPartyClientSdkConfig);
16
+ getParticipants(): Promise<Participant[]>;
17
+ getFallbackProviderParticipants(): Promise<Participant[]>;
18
+ getCurrentDate(): Date;
19
+ fetchParticipants(participantsUri: string): Promise<Participant[]>;
20
+ private retrieveFullParticipantsList;
21
+ sendPushedAuthorisationRequest(authServerId: string, essentialClaims: string[], voluntaryClaims?: string[], purpose?: string): Promise<{
22
+ authUrl: string;
23
+ code_verifier: string;
24
+ state: string;
25
+ nonce: string;
26
+ xFapiInteractionId: `${string}-${string}-${string}-${string}-${string}`;
27
+ }>;
28
+ retrieveTokens(authorisationServerId: string, requestParams: CallbackParamsType, codeVerifier: string, state: string, nonce: string): Promise<ConsolidatedTokenSet>;
29
+ private buildConsolidatedTokenSet;
30
+ getAuthServerDetails(authServerId: string): Promise<AuthorisationServer>;
31
+ private generateClaimsRequest;
32
+ getUserInfo(authorisationServerId: string, accessToken: string): Promise<import("openid-client").UserinfoResponse<import("openid-client").UnknownObject, import("openid-client").UnknownObject>>;
33
+ private getKeyset;
34
+ private setupClient;
35
+ private generateRequest;
36
+ private generateXFapiInteractionId;
37
+ }
@@ -0,0 +1,355 @@
1
+ // Simple API wrapper over the node-openid-client that provides utility methods
2
+ // supporting the specific implementation required for the ConnectID Digital Identity
3
+ // solution.
4
+ //
5
+ // The intent is the Relying Parties can use this as a simple SDK for interacting
6
+ // with the ConnectID solution.
7
+ import { exportJWK } from 'jose';
8
+ import { createPrivateKey, randomUUID } from 'crypto';
9
+ import { globalAgent } from 'node:https';
10
+ import { rootCertificates } from 'node:tls';
11
+ import { custom, Issuer } from 'openid-client';
12
+ import { getCertificate } from './utils/cert-utils.js';
13
+ import { partition } from './utils/functional-utils.js';
14
+ import { getLogger } from './logger.js';
15
+ import ParticipantFilters from './filter/participant-filters.js';
16
+ import { illegalPurposeChars, isValidCertificate, validatePurpose } from './validator.js';
17
+ import { generatePushAuthorisationRequestParams } from './utils/request-utils.js';
18
+ // The extended list of claims which can be requested for a user
19
+ const extendedClaimList = ['over16', 'over18', 'over21', 'over25', 'over65', 'beneficiary_account_au', 'beneficiary_account_au_payid', 'beneficiary_account_international'];
20
+ export default class RelyingPartyClientSdk {
21
+ constructor(config) {
22
+ this.purpose = 'verifying your identity';
23
+ this.participantFilters = new ParticipantFilters();
24
+ this.default_cache_ttl = 600;
25
+ this.cachedParticipantsExpiry = 0;
26
+ this.cachedParticipants = [];
27
+ this.config = config;
28
+ if (!isValidCertificate(this.config.data.transport_key, this.config.data.transport_key_content)) {
29
+ throw new Error('Either transport_key or transport_key_content must be provided');
30
+ }
31
+ if (!isValidCertificate(this.config.data.transport_pem, this.config.data.transport_pem_content)) {
32
+ throw new Error('Either transport_pem or transport_pem_content must be provided');
33
+ }
34
+ if (!isValidCertificate(this.config.data.signing_key, this.config.data.signing_key_content)) {
35
+ throw new Error('Either signing_key or signing_key_content must be provided');
36
+ }
37
+ if (!isValidCertificate(this.config.data.ca_pem, this.config.data.ca_pem_content)) {
38
+ throw new Error('Either ca_pem or ca_pem_content must be provided');
39
+ }
40
+ this.transportKey = getCertificate(this.config.data.transport_key, this.config.data.transport_key_content);
41
+ this.transportPem = getCertificate(this.config.data.transport_pem, this.config.data.transport_pem_content);
42
+ this.signingKey = getCertificate(this.config.data.signing_key, this.config.data.signing_key_content);
43
+ this.caPem = getCertificate(this.config.data.ca_pem, this.config.data.ca_pem_content);
44
+ this.logger = getLogger(this.config.data.log_level);
45
+ this.logger.info(`Creating RelyingPartyClientSdk - version 4.0.4`);
46
+ if (this.config.data.purpose) {
47
+ const purposeValidation = validatePurpose(this.config.data.purpose);
48
+ if (purposeValidation === 'INVALID_LENGTH') {
49
+ this.logger.warn('Purpose must be between 3 and 300 characters');
50
+ throw new Error(`Invalid purpose for supplied in config: ${this.config.data.purpose}`);
51
+ }
52
+ if (purposeValidation === 'INVALID_CHARACTERS') {
53
+ this.logger.warn(`Purpose cannot contain any of the following characters: ${illegalPurposeChars.join(',')}, purpose supplied: [${this.config.data.purpose}]`);
54
+ throw new Error(`Invalid purpose for supplied in config: ${this.config.data.purpose}`);
55
+ }
56
+ this.purpose = this.config.data.purpose;
57
+ this.logger.info(`Using default purpose supplied in config: ${this.purpose}`);
58
+ }
59
+ else {
60
+ this.logger.info(`Using built in default purpose: ${this.purpose}`);
61
+ }
62
+ if (this.config.data.include_uncertified_participants) {
63
+ this.logger.info(`Identity provider list will not be filtered as include_uncertified_participants=true`);
64
+ }
65
+ else {
66
+ if (this.config.data.required_claims) {
67
+ this.logger.info(`Identity provider list will be filtered for participants that support the following claims: ${JSON.stringify(this.config.data.required_claims)}`);
68
+ }
69
+ if (this.config.data.required_participant_certifications) {
70
+ this.logger.info(`Identity provider list will be filtered for participants that support the following certifications: ${JSON.stringify(this.config.data.required_participant_certifications)}`);
71
+ }
72
+ }
73
+ globalAgent.options.cert = this.transportPem;
74
+ globalAgent.options.key = this.transportKey;
75
+ globalAgent.options.ca = [this.caPem, ...rootCertificates];
76
+ custom.setHttpOptionsDefaults({ timeout: 10000 });
77
+ // 4.0.4 is replaced with `postbuild` script in package.json (see replace-in-files)
78
+ this.logger.info(`Using ${this.config.data.transport_key_content ? 'transport_key_content' : 'transport_key'} config prop`);
79
+ this.logger.info(`Using ${this.config.data.transport_pem_content ? 'transport_pem_content' : 'transport_pem'} config prop`);
80
+ this.logger.info(`Using ${this.config.data.ca_pem_content ? 'ca_pem_content' : 'ca_pem'} config prop`);
81
+ this.logger.info(`Using ${this.config.data.signing_key_content ? 'signing_key_content' : 'signing_key'} config prop`);
82
+ }
83
+ // Get the list of Participating DPs within the scheme and their metadata
84
+ async getParticipants() {
85
+ const participantsUri = this.config.data.registry_participants_uri;
86
+ try {
87
+ const participants = await this.retrieveFullParticipantsList(participantsUri);
88
+ this.logger.info(`Retrieved identity providers from ${participantsUri}, num orgs found: ${participants.length}`);
89
+ let filteredParticipants = this.participantFilters.removeFallbackIdentityServiceProvider(participants);
90
+ if (this.config.data.include_uncertified_participants) {
91
+ this.logger.info(`Identity provider list has not been filtered as include_uncertified_participants=true`);
92
+ filteredParticipants = this.participantFilters.removeParticipantsWithoutAuthServers(filteredParticipants);
93
+ return filteredParticipants;
94
+ }
95
+ filteredParticipants = this.participantFilters.removeOutOfDateCertifications(filteredParticipants, this.getCurrentDate());
96
+ filteredParticipants = this.participantFilters.removeUnofficialCertifications(filteredParticipants);
97
+ if (this.config.data.required_claims) {
98
+ this.logger.debug(`Identity provider list filtered for participants that support the following claims: ${JSON.stringify(this.config.data.required_claims)}`);
99
+ filteredParticipants = this.participantFilters.filterAuthServersForSupportedClaims(filteredParticipants, this.config.data.required_claims);
100
+ }
101
+ if (this.config.data.required_participant_certifications) {
102
+ this.logger.debug(`Identity provider list filtered for participants that support the following certifications: ${JSON.stringify(this.config.data.required_participant_certifications)}`);
103
+ filteredParticipants = this.participantFilters.filterForRequiredCertifications(filteredParticipants, this.config.data.required_participant_certifications);
104
+ }
105
+ filteredParticipants = this.participantFilters.removeInactiveAuthServers(filteredParticipants);
106
+ filteredParticipants = this.participantFilters.removeParticipantsWithoutAuthServers(filteredParticipants);
107
+ return filteredParticipants;
108
+ }
109
+ catch (e) {
110
+ throw new Error(`Unable to get participants from ${participantsUri}, ${e}`);
111
+ }
112
+ }
113
+ async getFallbackProviderParticipants() {
114
+ const participantsUri = this.config.data.registry_participants_uri;
115
+ try {
116
+ const participants = await this.retrieveFullParticipantsList(participantsUri);
117
+ this.logger.info(`Retrieved identity providers, num orgs found: ${participants.length}`);
118
+ let filteredParticipants = this.participantFilters.removeOutOfDateCertifications(participants, this.getCurrentDate());
119
+ filteredParticipants = this.participantFilters.removeUnofficialCertifications(filteredParticipants);
120
+ filteredParticipants = this.participantFilters.filterForFallbackIdentityServiceProviders(filteredParticipants);
121
+ filteredParticipants = this.participantFilters.removeParticipantsWithoutAuthServers(filteredParticipants);
122
+ return filteredParticipants;
123
+ }
124
+ catch (e) {
125
+ throw new Error(`Unable to get participants from ${participantsUri}, ${e}`);
126
+ }
127
+ }
128
+ // This method is public, so we can mock the current date in tests
129
+ getCurrentDate() {
130
+ return new Date();
131
+ }
132
+ async fetchParticipants(participantsUri) {
133
+ const response = await fetch(participantsUri);
134
+ if (!response.ok) {
135
+ throw new Error(`Failed to retrieve participants from ${participantsUri}: status (${response.status})`);
136
+ }
137
+ return await response.json();
138
+ }
139
+ async retrieveFullParticipantsList(participantsUri) {
140
+ const currentTime = Date.now();
141
+ if (currentTime > this.cachedParticipantsExpiry) {
142
+ this.cachedParticipants = await this.fetchParticipants(participantsUri);
143
+ this.cachedParticipantsExpiry = currentTime + (this.config.data.cache_ttl ?? this.default_cache_ttl) * 1000;
144
+ }
145
+ // ensure the cached value remain untouched down the call stack by returning a deep copy
146
+ return this.cachedParticipants.map(participant => Object.assign({}, participant));
147
+ }
148
+ // Create and send a pushed authorisation request to the specified authorisation
149
+ // server to allow the initiation of an OIDC flow.
150
+ async sendPushedAuthorisationRequest(authServerId, essentialClaims, voluntaryClaims = [], purpose = this.purpose) {
151
+ const purposeValidation = validatePurpose(purpose);
152
+ if (purposeValidation === 'INVALID_LENGTH') {
153
+ this.logger.warn('Purpose must be between 3 and 300 characters');
154
+ throw new Error(`Invalid purpose for supplied for PAR: ${purpose}`);
155
+ }
156
+ if (purposeValidation === 'INVALID_CHARACTERS') {
157
+ this.logger.warn(`Purpose cannot contain any of the following characters: ${illegalPurposeChars.join(',')}, purpose supplied: [${purpose}]`);
158
+ throw new Error(`Invalid purpose for supplied for PAR: ${purpose}`);
159
+ }
160
+ const xFapiInteractionId = this.generateXFapiInteractionId();
161
+ try {
162
+ const essentialClaimsWithTxn = [...new Set([...essentialClaims, 'txn'])];
163
+ const claimsRequest = this.generateClaimsRequest(essentialClaimsWithTxn, voluntaryClaims);
164
+ const authServer = await this.getAuthServerDetails(authServerId);
165
+ const { fapiClient } = await this.setupClient(authServer, xFapiInteractionId);
166
+ const { authUrl, code_verifier, state, nonce } = await this.generateRequest(fapiClient, claimsRequest, purpose);
167
+ this.logger.info(`Sent PAR to auth server: ${authServerId} - ${authServer.CustomerFriendlyName}, essential claims: ${essentialClaimsWithTxn}, voluntary claims: ${voluntaryClaims}, x-fapi-interaction-id: ${xFapiInteractionId}`);
168
+ return { authUrl, code_verifier, state, nonce, xFapiInteractionId };
169
+ }
170
+ catch (e) {
171
+ this.logger.error(`Error sending pushed authorisation request to authorisation server ${authServerId}, x-fapi-interaction-id: ${xFapiInteractionId}.`, e);
172
+ throw new Error(`Unable to send pushed authorisation request to ${authServerId}, x-fapi-interaction-id: ${xFapiInteractionId} - ${e}`);
173
+ }
174
+ }
175
+ // Process the callback response and return the tokens (access token and id token as a token set)
176
+ async retrieveTokens(authorisationServerId, requestParams, codeVerifier, state, nonce) {
177
+ const xFapiInteractionId = this.generateXFapiInteractionId();
178
+ try {
179
+ const authServer = await this.getAuthServerDetails(authorisationServerId);
180
+ const { fapiClient, localIssuer } = await this.setupClient(authServer, xFapiInteractionId);
181
+ const tokenSet = await fapiClient.callback(this.config.data.application_redirect_uri, requestParams, { code_verifier: codeVerifier, state, nonce, response_type: 'code' }, { clientAssertionPayload: { aud: localIssuer.metadata.issuer } });
182
+ this.logger.info(`Retrieved tokenSet from auth server: ${authorisationServerId} - ${authServer.CustomerFriendlyName}, x-fapi-interaction-id: ${xFapiInteractionId}, txn: ${tokenSet.claims().txn}`);
183
+ this.logger.debug(`Tokens returned from: ${authorisationServerId} - ${authServer.CustomerFriendlyName}: ${JSON.stringify(tokenSet)} claims: ${JSON.stringify(tokenSet.claims())}`);
184
+ // If using the OIDF test suite, need to make a call to userInfo when claims were successfully decoded
185
+ if (this.config.data.enable_auto_compliance_verification) {
186
+ this.logger.info('Auto compliance verification mode enabled, making call to userInfo since claims successfully decoded');
187
+ if (tokenSet.access_token != null) {
188
+ await this.getUserInfo(authorisationServerId, tokenSet.access_token);
189
+ }
190
+ }
191
+ return this.buildConsolidatedTokenSet(tokenSet, xFapiInteractionId);
192
+ }
193
+ catch (e) {
194
+ this.logger.error(`Error retrieving tokens with authorisation server ${authorisationServerId}, x-fapi-interaction-id: ${xFapiInteractionId}`, e);
195
+ throw new Error(`Unable to retrieve tokens with authorisation server ${authorisationServerId}, x-fapi-interaction-id: ${xFapiInteractionId}`);
196
+ }
197
+ }
198
+ buildConsolidatedTokenSet(tokenSet, xFapiInteractionId) {
199
+ return {
200
+ ...tokenSet,
201
+ xFapiInteractionId,
202
+ expired: () => tokenSet.expired(),
203
+ claims: () => tokenSet.claims(),
204
+ // @ts-ignore IdTokenClaims does not have `verified_claims` property (unknown)
205
+ consolidatedClaims: () => ({ ...tokenSet.claims(), ...tokenSet.claims().verified_claims?.claims }),
206
+ };
207
+ }
208
+ // Get the full details of an authorisation server given its AuthorisationServerId
209
+ async getAuthServerDetails(authServerId) {
210
+ const idps = await this.getParticipants();
211
+ const servers = idps.map(({ AuthorisationServers }) => AuthorisationServers).flat();
212
+ let found = servers.find(({ AuthorisationServerId }) => AuthorisationServerId === authServerId);
213
+ if (!found) {
214
+ // Check if it is one of the fallback servers
215
+ const fallbackIdps = await this.getFallbackProviderParticipants();
216
+ const fallbackServers = fallbackIdps.map(({ AuthorisationServers }) => AuthorisationServers).flat();
217
+ found = fallbackServers.find(({ AuthorisationServerId }) => AuthorisationServerId === authServerId);
218
+ if (!found) {
219
+ // Let the user know if was an auth server that was filtered out
220
+ const allAuthServers = (await this.retrieveFullParticipantsList(this.config.data.registry_participants_uri)).map(({ AuthorisationServers }) => AuthorisationServers).flat();
221
+ let exists = allAuthServers.find(({ AuthorisationServerId }) => AuthorisationServerId === authServerId);
222
+ let additionalLogInfo = exists ? ` Server exists but was filtered from results due to config settings for 'include_uncertified_participants', 'required_participant_certifications' and 'required_claims'` : '';
223
+ throw new Error(`Unable to find specified Authorisation Server: ${authServerId}${additionalLogInfo}`);
224
+ }
225
+ }
226
+ return found;
227
+ }
228
+ // Turn the list of claim names into the claims request to use as part of the PAR
229
+ generateClaimsRequest(essentialClaims, voluntaryClaims) {
230
+ this.logger.info(`Generating claims request with - essential claims: ${essentialClaims}, voluntary claims: ${voluntaryClaims}`);
231
+ const formattedVoluntaryClaims = voluntaryClaims.filter((claim) => !essentialClaims.includes(claim));
232
+ // Find the essential and voluntary claims.
233
+ const split = (claim) => extendedClaimList.includes(claim);
234
+ const [essentialExtendedClaims, filteredEssentialClaims] = partition(essentialClaims, split);
235
+ const [voluntaryExtendedClaims, filteredFormattedVoluntaryClaims] = partition(formattedVoluntaryClaims, split);
236
+ const buildExtendedClaims = (essentialExtendedClaims, voluntaryExtendedClaims) => [...essentialExtendedClaims, ...voluntaryExtendedClaims].reduce((acc, claim) => {
237
+ return {
238
+ ...acc,
239
+ [claim]: {
240
+ essential: essentialExtendedClaims.includes(claim),
241
+ },
242
+ };
243
+ }, {});
244
+ // If there are any extended claims, add them to the claims request.
245
+ const claimsRequest = { id_token: {} };
246
+ if (essentialExtendedClaims.length > 0 || voluntaryExtendedClaims.length > 0) {
247
+ claimsRequest.id_token.verified_claims = {
248
+ verification: {
249
+ trust_framework: {
250
+ value: 'au_connectid',
251
+ },
252
+ },
253
+ claims: buildExtendedClaims(essentialExtendedClaims, voluntaryExtendedClaims),
254
+ };
255
+ }
256
+ // Add the normal claims to the claims request.
257
+ filteredEssentialClaims.forEach((claim) => (claimsRequest.id_token[claim] = { essential: true }));
258
+ filteredFormattedVoluntaryClaims.forEach((claim) => (claimsRequest.id_token[claim] = { essential: false }));
259
+ this.logger.info(`Claims request: ${JSON.stringify(claimsRequest)}`);
260
+ return claimsRequest;
261
+ }
262
+ // Call the UserInfo endpoint to get the claims for the user
263
+ // TODO type return?!
264
+ async getUserInfo(authorisationServerId, accessToken) {
265
+ const authServer = await this.getAuthServerDetails(authorisationServerId);
266
+ const { fapiClient } = await this.setupClient(authServer, this.generateXFapiInteractionId());
267
+ return await fapiClient.userinfo(accessToken);
268
+ }
269
+ async getKeyset() {
270
+ const key = createPrivateKey(this.signingKey);
271
+ const privateJwk = await exportJWK(key);
272
+ privateJwk.kid = this.config.data.signing_kid;
273
+ this.logger.debug(`Create private jwk key ${JSON.stringify(privateJwk)}`);
274
+ /*
275
+ SDK bug with Node 16, the error is thrown in this function below because the JWK fails the !isPlainObject(k) condition.
276
+
277
+ // rp-nodejs-sdk/node_modules/openid-client/lib/client.js
278
+ function getKeystore(jwks) {
279
+ if (
280
+ !isPlainObject(jwks) ||
281
+ !Array.isArray(jwks.keys) ||
282
+ jwks.keys.some((k) => !isPlainObject(k) || !('kty' in k))
283
+ ) {
284
+ throw new TypeError('jwks must be a JSON Web Key Set formatted object');
285
+ }
286
+
287
+ return KeyStore.fromJWKS(jwks, { onlyPrivate: true });
288
+ }
289
+
290
+ Interesting that the code for isPlainObject is:
291
+ module.exports = (a) => !!a && a.constructor === Object;
292
+
293
+ And it returns false for a.constructor === Object. It returns [Function: Object]. So going deeper, I think the private key generated by const privateJwk = await fromKeyLike(key) is not compatible with the constructor even having the same type (JWK) as TS doesn't complain:
294
+ new (metadata: ClientMetadata, jwks?: { keys: jose.JWK[] }, options?: ClientOptions): TClient;
295
+
296
+ Might be different versions of JWK type (openid-client jose x jose)?!
297
+ To make it work then I tried recreating the JWK object using JSON.stringify and JSON.parse so the constructor returns an Object (?!)
298
+ ...and it worked :) So my guess is the way the JWK is generated using fromKeyLike that doesn't match the requirement when validating the JWK (constructor is not Object).
299
+
300
+ Why does it work on Node 14?
301
+ jwks.keys[0].constructor === Object on Node 14 returns true
302
+ jwks.keys[0].constructor === Object on Node 16 returns false
303
+ */
304
+ return { keys: [JSON.parse(JSON.stringify(privateJwk))] };
305
+ }
306
+ // Create a FAPI client for the specified authorisation server
307
+ async setupClient(authorisationServer, xFapiInteractionId) {
308
+ const response = await fetch(authorisationServer.OpenIDDiscoveryDocument);
309
+ if (!response.ok) {
310
+ throw new Error(`Failed to retrieve metadata from the authorisation server ${authorisationServer.AuthorisationServerId} at ${authorisationServer.OpenIDDiscoveryDocument}`);
311
+ }
312
+ const metadata = await response.json();
313
+ this.logger.debug(`Retrieved metadata for server ${authorisationServer.OpenIDDiscoveryDocument} ${JSON.stringify(metadata)}`);
314
+ for (const [key, value] of Object.entries(metadata?.mtls_endpoint_aliases)) {
315
+ metadata[key] = value;
316
+ }
317
+ this.logger.debug(`Updated endpoints with mtls alias properties for server ${authorisationServer.OpenIDDiscoveryDocument} ${JSON.stringify(metadata)}`);
318
+ const localIssuer = new Issuer(metadata);
319
+ this.logger.debug(`Discovered issuer ${localIssuer.issuer} ${JSON.stringify(localIssuer.metadata)}`);
320
+ const keyset = await this.getKeyset();
321
+ const fapiClient = new localIssuer.FAPI1Client(this.config.data.client, keyset);
322
+ this.logger.debug(`Discovered client ${JSON.stringify(fapiClient)}`);
323
+ fapiClient[custom.http_options] = () => ({ key: this.transportKey, cert: this.transportPem, headers: { 'x-fapi-interaction-id': xFapiInteractionId } });
324
+ return { fapiClient, localIssuer };
325
+ }
326
+ async generateRequest(fapiClient, claims, purpose) {
327
+ const { codeChallenge, codeVerifier, nonce, state } = generatePushAuthorisationRequestParams();
328
+ const request = await fapiClient.requestObject({
329
+ scope: 'openid',
330
+ response_type: 'code',
331
+ redirect_uri: this.config.data.application_redirect_uri,
332
+ code_challenge: codeChallenge,
333
+ code_challenge_method: 'S256',
334
+ response_mode: 'query',
335
+ state,
336
+ nonce,
337
+ claims,
338
+ prompt: 'consent',
339
+ max_age: 900,
340
+ purpose,
341
+ });
342
+ const clientAssertionPayload = {
343
+ clientAssertionPayload: {
344
+ aud: fapiClient.issuer.issuer
345
+ }
346
+ };
347
+ this.logger.debug('Generated request object: ' + JSON.stringify(request));
348
+ const { request_uri } = await fapiClient.pushedAuthorizationRequest({ request }, clientAssertionPayload);
349
+ const authUrl = fapiClient.authorizationUrl({ request_uri, scope: undefined, redirect_uri: undefined, response_type: undefined });
350
+ return { authUrl, code_verifier: codeVerifier, state, nonce };
351
+ }
352
+ generateXFapiInteractionId() {
353
+ return randomUUID();
354
+ }
355
+ }
package/types.d.ts ADDED
@@ -0,0 +1,168 @@
1
+ import { IdTokenClaims, TokenSet } from 'openid-client';
2
+ export type RelyingPartyClientSdkConfig = {
3
+ data: {
4
+ ca_pem?: string;
5
+ ca_pem_content?: string;
6
+ signing_kid: string;
7
+ signing_key?: string;
8
+ signing_key_content?: string;
9
+ signing_pem?: string;
10
+ signing_pem_content?: string;
11
+ transport_key?: string;
12
+ transport_key_content?: string;
13
+ transport_pem?: string;
14
+ transport_pem_content?: string;
15
+ application_redirect_uri: string;
16
+ registry_participants_uri: string;
17
+ include_uncertified_participants?: boolean;
18
+ required_participant_certifications?: CertificationFilter[];
19
+ required_claims?: string[];
20
+ log_level: 'debug' | 'info';
21
+ enable_auto_compliance_verification: boolean;
22
+ purpose?: string;
23
+ cache_ttl?: number;
24
+ client: {
25
+ client_id: string;
26
+ organisation_id: string;
27
+ jwks_uri: string;
28
+ redirect_uris: string[];
29
+ organisation_name: string;
30
+ organisation_number: string;
31
+ software_description: string;
32
+ software_roles: string[];
33
+ application_type: 'web';
34
+ grant_types: ['client_credentials', 'authorization_code', 'implicit'];
35
+ id_token_signed_response_alg: 'PS256';
36
+ post_logout_redirect_uris: [];
37
+ require_auth_time: false;
38
+ response_types: ['code id_token', 'code'];
39
+ subject_type: 'public';
40
+ token_endpoint_auth_method: 'private_key_jwt';
41
+ token_endpoint_auth_signing_alg: 'PS256';
42
+ introspection_endpoint_auth_method: 'private_key_jwt';
43
+ revocation_endpoint_auth_method: 'private_key_jwt';
44
+ request_object_signing_alg: 'PS256';
45
+ require_signed_request_object: true;
46
+ require_pushed_authorization_requests: true;
47
+ authorization_signed_response_alg: 'PS256';
48
+ tls_client_certificate_bound_access_tokens: true;
49
+ backchannel_user_code_parameter: false;
50
+ scope: 'openid';
51
+ };
52
+ };
53
+ };
54
+ export type Participant = {
55
+ OrganisationId: string;
56
+ Status: string;
57
+ OrganisationName: string;
58
+ CreatedOn: string;
59
+ LegalEntityName: string;
60
+ CountryOfRegistration: string;
61
+ CompanyRegister: string;
62
+ Tag: string[];
63
+ Size: string;
64
+ RegistrationNumber: string;
65
+ RegistrationId: string | null;
66
+ RegisteredName: string;
67
+ AddressLine1: string;
68
+ AddressLine2: string;
69
+ City: string;
70
+ Postcode: string;
71
+ Country: string;
72
+ ParentOrganisationReference: string;
73
+ AuthorisationServers: AuthorisationServer[];
74
+ OrgDomainClaims: OrgDomainClaim[];
75
+ OrgDomainRoleClaims: OrgDomainRoleClaim[];
76
+ };
77
+ export type AuthorisationServer = {
78
+ AuthorisationServerId: string;
79
+ AutoRegistrationSupported: boolean;
80
+ AutoRegistrationNotificationWebhook: string;
81
+ SupportsCiba: boolean;
82
+ SupportsDCR: boolean;
83
+ ApiResources: ApiResource[];
84
+ AuthorisationServerCertifications: AuthorisationServerCertification[];
85
+ CustomerFriendlyDescription: string;
86
+ CustomerFriendlyLogoUri: string;
87
+ CustomerFriendlyName: string;
88
+ DeveloperPortalUri: string | null;
89
+ TermsOfServiceUri: string | null;
90
+ NotificationWebhookAddedDate: string | null;
91
+ OpenIDDiscoveryDocument: string;
92
+ Issuer: string;
93
+ PayloadSigningCertLocationUri: string;
94
+ ParentAuthorisationServerId: string | null;
95
+ DeprecatedDate: Date | null;
96
+ RetirementDate: Date | null;
97
+ SupersededByAuthorisationServerId: string | null;
98
+ };
99
+ export type ApiResource = {
100
+ ApiResourceId: string;
101
+ ApiVersion: string;
102
+ ApiDiscoveryEndpoints: ApiDiscoveryEndpoint[];
103
+ FamilyComplete: boolean;
104
+ ApiCertificationUri: string;
105
+ CertificationStatus: string;
106
+ CertificationStartDate?: Date;
107
+ CertificationExpirationDate?: Date;
108
+ ApiFamilyType: string;
109
+ };
110
+ export type ApiDiscoveryEndpoint = {
111
+ ApiDiscoveryId: string;
112
+ ApiEndpoint: string;
113
+ };
114
+ export type AuthorisationServerCertification = {
115
+ CertificationStartDate: string;
116
+ CertificationExpirationDate: string;
117
+ CertificationId: string;
118
+ AuthorisationServerId: string;
119
+ Status: string;
120
+ ProfileVariant: string;
121
+ ProfileType: string;
122
+ ProfileVersion: number;
123
+ CertificationURI: string;
124
+ };
125
+ export type OrgDomainClaim = {
126
+ AuthorisationDomainName: string;
127
+ AuthorityName: string;
128
+ RegistrationId: string;
129
+ Status: string;
130
+ };
131
+ export type OrgDomainRoleClaim = {
132
+ Status: string;
133
+ AuthorisationDomain: string;
134
+ Role: string;
135
+ Authorisations: any[];
136
+ RegistrationId: string;
137
+ };
138
+ export type ClaimsRequest = {
139
+ id_token: {
140
+ [key: string]: any;
141
+ verified_claims?: {
142
+ verification: {
143
+ trust_framework: {
144
+ value: string;
145
+ } | null;
146
+ };
147
+ claims?: {
148
+ [key: string]: {
149
+ essential: boolean;
150
+ };
151
+ };
152
+ };
153
+ };
154
+ };
155
+ export type ConsolidatedTokenSet = TokenSet & {
156
+ consolidatedClaims(): IdTokenClaims;
157
+ };
158
+ export type CertificationFilter = {
159
+ profileVariant: string;
160
+ profileType: string;
161
+ };
162
+ export type PushAuthorisationRequestParams = {
163
+ state: string;
164
+ nonce: string;
165
+ codeVerifier: string;
166
+ codeChallenge: string;
167
+ };
168
+ export type PurposeValidation = 'INVALID_LENGTH' | 'INVALID_CHARACTERS' | 'VALID';
package/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export declare const getCertificate: (certificatePath?: string, certificateContent?: string) => string | Buffer<ArrayBufferLike>;
@@ -0,0 +1,2 @@
1
+ import { readFileSync } from 'fs';
2
+ export const getCertificate = (certificatePath, certificateContent) => certificateContent || readFileSync(certificatePath);
@@ -0,0 +1 @@
1
+ export declare function partition<T>(array: T[], predicate: (value: T) => boolean): [T[], T[]];
@@ -0,0 +1,12 @@
1
+ export function partition(array, predicate) {
2
+ const [matches, nonMatches] = [[], []];
3
+ for (const element of array) {
4
+ if (predicate(element)) {
5
+ matches.push(element);
6
+ }
7
+ else {
8
+ nonMatches.push(element);
9
+ }
10
+ }
11
+ return [matches, nonMatches];
12
+ }
@@ -0,0 +1,2 @@
1
+ import { PushAuthorisationRequestParams } from '../types';
2
+ export declare const generatePushAuthorisationRequestParams: () => PushAuthorisationRequestParams;
@@ -0,0 +1,8 @@
1
+ import { generators } from 'openid-client';
2
+ export const generatePushAuthorisationRequestParams = () => {
3
+ const state = generators.state();
4
+ const nonce = generators.nonce();
5
+ const codeVerifier = generators.codeVerifier();
6
+ const codeChallenge = generators.codeChallenge(codeVerifier);
7
+ return { state, nonce, codeVerifier, codeChallenge };
8
+ };
package/validator.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ import { PurposeValidation } from './types';
2
+ export declare const illegalPurposeChars: string[];
3
+ export declare const validatePurpose: (purpose: string) => PurposeValidation;
4
+ export declare const isValidCertificate: (certificateFilePath?: string, certificateContent?: string) => boolean;
package/validator.js ADDED
@@ -0,0 +1,12 @@
1
+ export const illegalPurposeChars = ['<', '>', '(', ')', '{', '}', "'", '\\'];
2
+ export const validatePurpose = (purpose) => {
3
+ if (purpose.length < 3 || purpose.length > 300) {
4
+ return 'INVALID_LENGTH';
5
+ }
6
+ const containsIllegalChar = illegalPurposeChars.some((char) => purpose.includes(char));
7
+ if (containsIllegalChar) {
8
+ return 'INVALID_CHARACTERS';
9
+ }
10
+ return 'VALID';
11
+ };
12
+ export const isValidCertificate = (certificateFilePath, certificateContent) => !!(certificateFilePath || certificateContent);