@boxyhq/saml-jackson 0.2.3-beta.211 → 0.2.3-beta.223
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/.eslintrc.js +13 -0
- package/package.json +7 -7
- package/prettier.config.js +4 -0
- package/src/controller/api.ts +225 -0
- package/src/controller/error.ts +13 -0
- package/src/controller/oauth/allowed.ts +22 -0
- package/src/controller/oauth/code-verifier.ts +11 -0
- package/src/controller/oauth/redirect.ts +12 -0
- package/src/controller/oauth.ts +337 -0
- package/src/controller/utils.ts +17 -0
- package/src/db/db.ts +100 -0
- package/src/db/encrypter.ts +38 -0
- package/src/db/mem.ts +128 -0
- package/src/db/mongo.ts +110 -0
- package/src/db/redis.ts +103 -0
- package/src/db/sql/entity/JacksonIndex.ts +44 -0
- package/src/db/sql/entity/JacksonStore.ts +43 -0
- package/src/db/sql/entity/JacksonTTL.ts +17 -0
- package/src/db/sql/model/JacksonIndex.ts +3 -0
- package/src/db/sql/model/JacksonStore.ts +8 -0
- package/src/db/sql/sql.ts +184 -0
- package/src/db/store.ts +49 -0
- package/src/db/utils.ts +26 -0
- package/src/env.ts +42 -0
- package/src/index.ts +79 -0
- package/src/jackson.ts +171 -0
- package/src/read-config.ts +29 -0
- package/src/saml/claims.ts +41 -0
- package/src/saml/saml.ts +234 -0
- package/src/saml/x509.ts +51 -0
- package/src/test/api.test.ts +271 -0
- package/src/test/data/metadata/boxyhq.js +6 -0
- package/src/test/data/metadata/boxyhq.xml +30 -0
- package/src/test/data/saml_response +1 -0
- package/src/test/db.test.ts +313 -0
- package/src/test/oauth.test.ts +353 -0
- package/src/typings.ts +167 -0
- package/tsconfig.build.json +6 -0
- package/tsconfig.json +26 -0
- package/.nyc_output/6029973c-8376-400d-8c60-3f9c15355a7c.json +0 -1
- package/.nyc_output/8ba10db0-04c4-4cdf-942b-ac167533371d.json +0 -1
- package/.nyc_output/8d461421-b177-41b5-880e-a4165b020e26.json +0 -1
- package/.nyc_output/processinfo/6029973c-8376-400d-8c60-3f9c15355a7c.json +0 -1
- package/.nyc_output/processinfo/8ba10db0-04c4-4cdf-942b-ac167533371d.json +0 -1
- package/.nyc_output/processinfo/8d461421-b177-41b5-880e-a4165b020e26.json +0 -1
- package/.nyc_output/processinfo/index.json +0 -1
package/src/saml/saml.ts
ADDED
@@ -0,0 +1,234 @@
|
|
1
|
+
const saml = require('@boxyhq/saml20');
|
2
|
+
const xml2js = require('xml2js');
|
3
|
+
const thumbprint = require('thumbprint');
|
4
|
+
const xmlcrypto = require('xml-crypto');
|
5
|
+
import * as rambda from 'rambda';
|
6
|
+
import xmlbuilder from 'xmlbuilder';
|
7
|
+
import crypto from 'crypto';
|
8
|
+
import claims from './claims';
|
9
|
+
import { SAMLProfile, SAMLReq } from 'saml-jackson';
|
10
|
+
|
11
|
+
const idPrefix = '_';
|
12
|
+
const authnXPath =
|
13
|
+
'/*[local-name(.)="AuthnRequest" and namespace-uri(.)="urn:oasis:names:tc:SAML:2.0:protocol"]';
|
14
|
+
const issuerXPath =
|
15
|
+
'/*[local-name(.)="Issuer" and namespace-uri(.)="urn:oasis:names:tc:SAML:2.0:assertion"]';
|
16
|
+
|
17
|
+
const signRequest = (xml: string, signingKey: string) => {
|
18
|
+
if (!xml) {
|
19
|
+
throw new Error('Please specify xml');
|
20
|
+
}
|
21
|
+
if (!signingKey) {
|
22
|
+
throw new Error('Please specify signingKey');
|
23
|
+
}
|
24
|
+
|
25
|
+
const sig = new xmlcrypto.SignedXml();
|
26
|
+
sig.signatureAlgorithm = 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256';
|
27
|
+
sig.signingKey = signingKey;
|
28
|
+
sig.addReference(
|
29
|
+
authnXPath,
|
30
|
+
[
|
31
|
+
'http://www.w3.org/2000/09/xmldsig#enveloped-signature',
|
32
|
+
'http://www.w3.org/2001/10/xml-exc-c14n#',
|
33
|
+
],
|
34
|
+
'http://www.w3.org/2001/04/xmlenc#sha256'
|
35
|
+
);
|
36
|
+
sig.computeSignature(xml, {
|
37
|
+
location: { reference: authnXPath + issuerXPath, action: 'after' },
|
38
|
+
});
|
39
|
+
|
40
|
+
return sig.getSignedXml();
|
41
|
+
};
|
42
|
+
|
43
|
+
const request = ({
|
44
|
+
ssoUrl,
|
45
|
+
entityID,
|
46
|
+
callbackUrl,
|
47
|
+
isPassive = false,
|
48
|
+
forceAuthn = false,
|
49
|
+
identifierFormat = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
|
50
|
+
providerName = 'BoxyHQ',
|
51
|
+
signingKey,
|
52
|
+
}: SAMLReq): { id: string; request: string } => {
|
53
|
+
const id = idPrefix + crypto.randomBytes(10).toString('hex');
|
54
|
+
const date = new Date().toISOString();
|
55
|
+
|
56
|
+
let samlReq: Record<string, any> = {
|
57
|
+
'samlp:AuthnRequest': {
|
58
|
+
'@xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol',
|
59
|
+
'@ID': id,
|
60
|
+
'@Version': '2.0',
|
61
|
+
'@IssueInstant': date,
|
62
|
+
'@ProtocolBinding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
|
63
|
+
'@Destination': ssoUrl,
|
64
|
+
'saml:Issuer': {
|
65
|
+
'@xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion',
|
66
|
+
'#text': entityID,
|
67
|
+
},
|
68
|
+
},
|
69
|
+
};
|
70
|
+
|
71
|
+
if (isPassive) samlReq['samlp:AuthnRequest']['@IsPassive'] = true;
|
72
|
+
|
73
|
+
if (forceAuthn) {
|
74
|
+
samlReq['samlp:AuthnRequest']['@ForceAuthn'] = true;
|
75
|
+
}
|
76
|
+
|
77
|
+
samlReq['samlp:AuthnRequest']['@AssertionConsumerServiceURL'] = callbackUrl;
|
78
|
+
|
79
|
+
samlReq['samlp:AuthnRequest']['samlp:NameIDPolicy'] = {
|
80
|
+
'@xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol',
|
81
|
+
'@Format': identifierFormat,
|
82
|
+
'@AllowCreate': 'true',
|
83
|
+
};
|
84
|
+
|
85
|
+
if (providerName != null) {
|
86
|
+
samlReq['samlp:AuthnRequest']['@ProviderName'] = providerName;
|
87
|
+
}
|
88
|
+
|
89
|
+
let xml = xmlbuilder.create(samlReq).end({});
|
90
|
+
if (signingKey) {
|
91
|
+
xml = signRequest(xml, signingKey);
|
92
|
+
}
|
93
|
+
|
94
|
+
return {
|
95
|
+
id,
|
96
|
+
request: xml,
|
97
|
+
};
|
98
|
+
};
|
99
|
+
|
100
|
+
const parseAsync = async (
|
101
|
+
rawAssertion: string
|
102
|
+
): Promise<SAMLProfile | void> => {
|
103
|
+
return new Promise((resolve, reject) => {
|
104
|
+
saml.parse(
|
105
|
+
rawAssertion,
|
106
|
+
function onParseAsync(err: Error, profile: SAMLProfile) {
|
107
|
+
if (err) {
|
108
|
+
reject(err);
|
109
|
+
return;
|
110
|
+
}
|
111
|
+
|
112
|
+
resolve(profile);
|
113
|
+
}
|
114
|
+
);
|
115
|
+
});
|
116
|
+
};
|
117
|
+
|
118
|
+
const validateAsync = async (
|
119
|
+
rawAssertion: string,
|
120
|
+
options
|
121
|
+
): Promise<SAMLProfile | void> => {
|
122
|
+
return new Promise((resolve, reject) => {
|
123
|
+
saml.validate(
|
124
|
+
rawAssertion,
|
125
|
+
options,
|
126
|
+
function onValidateAsync(err, profile: SAMLProfile) {
|
127
|
+
if (err) {
|
128
|
+
reject(err);
|
129
|
+
return;
|
130
|
+
}
|
131
|
+
|
132
|
+
if (profile && profile.claims) {
|
133
|
+
// we map claims to our attributes id, email, firstName, lastName where possible. We also map original claims to raw
|
134
|
+
profile.claims = claims.map(profile.claims);
|
135
|
+
|
136
|
+
// some providers don't return the id in the assertion, we set it to a sha256 hash of the email
|
137
|
+
if (!profile.claims.id) {
|
138
|
+
profile.claims.id = crypto
|
139
|
+
.createHash('sha256')
|
140
|
+
.update(profile.claims.email)
|
141
|
+
.digest('hex');
|
142
|
+
}
|
143
|
+
}
|
144
|
+
|
145
|
+
resolve(profile);
|
146
|
+
}
|
147
|
+
);
|
148
|
+
});
|
149
|
+
};
|
150
|
+
|
151
|
+
const parseMetadataAsync = async (
|
152
|
+
idpMeta: string
|
153
|
+
): Promise<Record<string, any>> => {
|
154
|
+
return new Promise((resolve, reject) => {
|
155
|
+
xml2js.parseString(
|
156
|
+
idpMeta,
|
157
|
+
{ tagNameProcessors: [xml2js.processors.stripPrefix] },
|
158
|
+
(err: Error, res) => {
|
159
|
+
if (err) {
|
160
|
+
reject(err);
|
161
|
+
return;
|
162
|
+
}
|
163
|
+
|
164
|
+
const entityID = rambda.path('EntityDescriptor.$.entityID', res);
|
165
|
+
let X509Certificate = null;
|
166
|
+
let ssoPostUrl: null | undefined = null;
|
167
|
+
let ssoRedirectUrl: null | undefined = null;
|
168
|
+
let loginType = 'idp';
|
169
|
+
|
170
|
+
let ssoDes: any = rambda.pathOr(
|
171
|
+
null,
|
172
|
+
'EntityDescriptor.IDPSSODescriptor',
|
173
|
+
res
|
174
|
+
);
|
175
|
+
if (!ssoDes) {
|
176
|
+
ssoDes = rambda.pathOr([], 'EntityDescriptor.SPSSODescriptor', res);
|
177
|
+
if (!ssoDes) {
|
178
|
+
loginType = 'sp';
|
179
|
+
}
|
180
|
+
}
|
181
|
+
|
182
|
+
for (const ssoDesRec of ssoDes) {
|
183
|
+
const keyDes = ssoDesRec['KeyDescriptor'];
|
184
|
+
for (const keyDesRec of keyDes) {
|
185
|
+
if (keyDesRec['$'] && keyDesRec['$'].use === 'signing') {
|
186
|
+
const ki = keyDesRec['KeyInfo'][0];
|
187
|
+
const cd = ki['X509Data'][0];
|
188
|
+
X509Certificate = cd['X509Certificate'][0];
|
189
|
+
}
|
190
|
+
}
|
191
|
+
|
192
|
+
const ssoSvc =
|
193
|
+
ssoDesRec['SingleSignOnService'] ||
|
194
|
+
ssoDesRec['AssertionConsumerService'] ||
|
195
|
+
[];
|
196
|
+
for (const ssoSvcRec of ssoSvc) {
|
197
|
+
if (
|
198
|
+
rambda.pathOr('', '$.Binding', ssoSvcRec).endsWith('HTTP-POST')
|
199
|
+
) {
|
200
|
+
ssoPostUrl = rambda.path('$.Location', ssoSvcRec);
|
201
|
+
} else if (
|
202
|
+
rambda
|
203
|
+
.pathOr('', '$.Binding', ssoSvcRec)
|
204
|
+
.endsWith('HTTP-Redirect')
|
205
|
+
) {
|
206
|
+
ssoRedirectUrl = rambda.path('$.Location', ssoSvcRec);
|
207
|
+
}
|
208
|
+
}
|
209
|
+
}
|
210
|
+
|
211
|
+
const ret: Record<string, any> = {
|
212
|
+
sso: {},
|
213
|
+
};
|
214
|
+
if (entityID) {
|
215
|
+
ret.entityID = entityID;
|
216
|
+
}
|
217
|
+
if (X509Certificate) {
|
218
|
+
ret.thumbprint = thumbprint.calculate(X509Certificate);
|
219
|
+
}
|
220
|
+
if (ssoPostUrl) {
|
221
|
+
ret.sso.postUrl = ssoPostUrl;
|
222
|
+
}
|
223
|
+
if (ssoRedirectUrl) {
|
224
|
+
ret.sso.redirectUrl = ssoRedirectUrl;
|
225
|
+
}
|
226
|
+
ret.loginType = loginType;
|
227
|
+
|
228
|
+
resolve(ret);
|
229
|
+
}
|
230
|
+
);
|
231
|
+
});
|
232
|
+
};
|
233
|
+
|
234
|
+
export default { request, parseAsync, validateAsync, parseMetadataAsync };
|
package/src/saml/x509.ts
ADDED
@@ -0,0 +1,51 @@
|
|
1
|
+
import * as x509 from '@peculiar/x509';
|
2
|
+
import { Crypto } from '@peculiar/webcrypto';
|
3
|
+
|
4
|
+
const crypto = new Crypto();
|
5
|
+
x509.cryptoProvider.set(crypto);
|
6
|
+
|
7
|
+
const alg = {
|
8
|
+
name: 'RSASSA-PKCS1-v1_5',
|
9
|
+
hash: 'SHA-256',
|
10
|
+
publicExponent: new Uint8Array([1, 0, 1]),
|
11
|
+
modulusLength: 2048,
|
12
|
+
};
|
13
|
+
|
14
|
+
const generate = async () => {
|
15
|
+
const keys = await crypto.subtle.generateKey(alg, true, ['sign', 'verify']);
|
16
|
+
|
17
|
+
const extensions: x509.Extension[] = [
|
18
|
+
new x509.BasicConstraintsExtension(false, undefined, true),
|
19
|
+
];
|
20
|
+
|
21
|
+
extensions.push(
|
22
|
+
new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature, true)
|
23
|
+
);
|
24
|
+
if (keys.publicKey) {
|
25
|
+
extensions.push(
|
26
|
+
await x509.SubjectKeyIdentifierExtension.create(keys.publicKey)
|
27
|
+
);
|
28
|
+
}
|
29
|
+
|
30
|
+
const cert = await x509.X509CertificateGenerator.createSelfSigned({
|
31
|
+
serialNumber: '01',
|
32
|
+
name: 'CN=BoxyHQ Jackson',
|
33
|
+
notBefore: new Date(),
|
34
|
+
notAfter: new Date('3021/01/01'), // TODO: set shorter expiry and rotate ceritifcates
|
35
|
+
signingAlgorithm: alg,
|
36
|
+
keys: keys,
|
37
|
+
extensions,
|
38
|
+
});
|
39
|
+
if (keys.privateKey) {
|
40
|
+
const pkcs8 = await crypto.subtle.exportKey('pkcs8', keys.privateKey);
|
41
|
+
|
42
|
+
return {
|
43
|
+
publicKey: cert.toString('pem'),
|
44
|
+
privateKey: x509.PemConverter.encode(pkcs8, 'private key'),
|
45
|
+
};
|
46
|
+
}
|
47
|
+
};
|
48
|
+
|
49
|
+
export default {
|
50
|
+
generate,
|
51
|
+
};
|
@@ -0,0 +1,271 @@
|
|
1
|
+
import * as path from 'path';
|
2
|
+
import { DatabaseOption, IdPConfig } from 'saml-jackson';
|
3
|
+
import sinon from 'sinon';
|
4
|
+
import tap from 'tap';
|
5
|
+
import * as dbutils from '../db/utils';
|
6
|
+
import controllers from '../index';
|
7
|
+
import readConfig from '../read-config';
|
8
|
+
|
9
|
+
// TODO: Add the type
|
10
|
+
let apiController;
|
11
|
+
|
12
|
+
const CLIENT_ID = '75edb050796a0eb1cf2cfb0da7245f85bc50baa7';
|
13
|
+
const PROVIDER = 'accounts.google.com';
|
14
|
+
const OPTIONS = {
|
15
|
+
externalUrl: 'https://my-cool-app.com',
|
16
|
+
samlAudience: 'https://saml.boxyhq.com',
|
17
|
+
samlPath: '/sso/oauth/saml',
|
18
|
+
db: {
|
19
|
+
engine: 'mongo',
|
20
|
+
url: 'mongodb://localhost:27017/jackson-demo',
|
21
|
+
} as DatabaseOption,
|
22
|
+
};
|
23
|
+
|
24
|
+
tap.before(async () => {
|
25
|
+
const controller = await controllers(OPTIONS);
|
26
|
+
|
27
|
+
apiController = controller.apiController;
|
28
|
+
});
|
29
|
+
|
30
|
+
tap.teardown(async () => {
|
31
|
+
process.exit(0);
|
32
|
+
});
|
33
|
+
|
34
|
+
tap.test('controller/api', async (t) => {
|
35
|
+
const metadataPath = path.join(__dirname, '/data/metadata');
|
36
|
+
const config = await readConfig(metadataPath);
|
37
|
+
|
38
|
+
t.afterEach(async () => {
|
39
|
+
await apiController.deleteConfig({
|
40
|
+
tenant: config[0].tenant,
|
41
|
+
product: config[0].product,
|
42
|
+
});
|
43
|
+
});
|
44
|
+
|
45
|
+
t.test('Create the config', async (t) => {
|
46
|
+
t.test('when required fields are missing or invalid', async (t) => {
|
47
|
+
t.test('when `rawMetadata` is empty', async (t) => {
|
48
|
+
const body: Partial<IdPConfig> = Object.assign({}, config[0]);
|
49
|
+
delete body['rawMetadata'];
|
50
|
+
|
51
|
+
try {
|
52
|
+
await apiController.config(body);
|
53
|
+
t.fail('Expecting JacksonError.');
|
54
|
+
} catch (err: any) {
|
55
|
+
t.equal(err.message, 'Please provide rawMetadata');
|
56
|
+
t.equal(err.statusCode, 400);
|
57
|
+
}
|
58
|
+
|
59
|
+
t.end();
|
60
|
+
});
|
61
|
+
|
62
|
+
t.test('when `defaultRedirectUrl` is empty', async (t) => {
|
63
|
+
const body: Partial<IdPConfig> = Object.assign({}, config[0]);
|
64
|
+
delete body['defaultRedirectUrl'];
|
65
|
+
|
66
|
+
try {
|
67
|
+
await apiController.config(body);
|
68
|
+
t.fail('Expecting JacksonError.');
|
69
|
+
} catch (err: any) {
|
70
|
+
t.equal(err.message, 'Please provide a defaultRedirectUrl');
|
71
|
+
t.equal(err.statusCode, 400);
|
72
|
+
}
|
73
|
+
|
74
|
+
t.end();
|
75
|
+
});
|
76
|
+
|
77
|
+
t.test('when `redirectUrl` is empty', async (t) => {
|
78
|
+
const body: Partial<IdPConfig> = Object.assign({}, config[0]);
|
79
|
+
delete body['redirectUrl'];
|
80
|
+
|
81
|
+
try {
|
82
|
+
await apiController.config(body);
|
83
|
+
t.fail('Expecting JacksonError.');
|
84
|
+
} catch (err: any) {
|
85
|
+
t.equal(err.message, 'Please provide redirectUrl');
|
86
|
+
t.equal(err.statusCode, 400);
|
87
|
+
}
|
88
|
+
|
89
|
+
t.end();
|
90
|
+
});
|
91
|
+
|
92
|
+
t.test('when `tenant` is empty', async (t) => {
|
93
|
+
const body: Partial<IdPConfig> = Object.assign({}, config[0]);
|
94
|
+
delete body['tenant'];
|
95
|
+
|
96
|
+
try {
|
97
|
+
await apiController.config(body);
|
98
|
+
t.fail('Expecting JacksonError.');
|
99
|
+
} catch (err: any) {
|
100
|
+
t.equal(err.message, 'Please provide tenant');
|
101
|
+
t.equal(err.statusCode, 400);
|
102
|
+
}
|
103
|
+
|
104
|
+
t.end();
|
105
|
+
});
|
106
|
+
|
107
|
+
t.test('when `product` is empty', async (t) => {
|
108
|
+
const body: Partial<IdPConfig> = Object.assign({}, config[0]);
|
109
|
+
delete body['product'];
|
110
|
+
|
111
|
+
try {
|
112
|
+
await apiController.config(body);
|
113
|
+
t.fail('Expecting JacksonError.');
|
114
|
+
} catch (err: any) {
|
115
|
+
t.equal(err.message, 'Please provide product');
|
116
|
+
t.equal(err.statusCode, 400);
|
117
|
+
}
|
118
|
+
|
119
|
+
t.end();
|
120
|
+
});
|
121
|
+
|
122
|
+
t.test('when `rawMetadata` is not a valid XML', async (t) => {
|
123
|
+
const body = Object.assign({}, config[0]);
|
124
|
+
body['rawMetadata'] = 'not a valid XML';
|
125
|
+
|
126
|
+
try {
|
127
|
+
await apiController.config(body);
|
128
|
+
t.fail('Expecting Error.');
|
129
|
+
} catch (err: any) {
|
130
|
+
t.match(err.message, /Non-whitespace before first tag./);
|
131
|
+
}
|
132
|
+
|
133
|
+
t.end();
|
134
|
+
});
|
135
|
+
});
|
136
|
+
|
137
|
+
t.test('when the request is good', async (t) => {
|
138
|
+
const body = Object.assign({}, config[0]);
|
139
|
+
|
140
|
+
const kdStub = sinon.stub(dbutils, 'keyDigest').returns(CLIENT_ID);
|
141
|
+
|
142
|
+
const response = await apiController.config(body);
|
143
|
+
|
144
|
+
t.ok(kdStub.called);
|
145
|
+
t.equal(response.client_id, CLIENT_ID);
|
146
|
+
t.equal(response.provider, PROVIDER);
|
147
|
+
|
148
|
+
let savedConf = await apiController.getConfig({
|
149
|
+
clientID: CLIENT_ID,
|
150
|
+
});
|
151
|
+
|
152
|
+
t.equal(savedConf.provider, PROVIDER);
|
153
|
+
|
154
|
+
kdStub.restore();
|
155
|
+
|
156
|
+
t.end();
|
157
|
+
});
|
158
|
+
|
159
|
+
t.end();
|
160
|
+
});
|
161
|
+
|
162
|
+
t.test('Get the config', async (t) => {
|
163
|
+
t.test('when valid request', async (t) => {
|
164
|
+
const body: Partial<IdPConfig> = Object.assign({}, config[0]);
|
165
|
+
|
166
|
+
await apiController.config(body);
|
167
|
+
|
168
|
+
const { provider } = await apiController.getConfig(body);
|
169
|
+
|
170
|
+
t.equal(provider, PROVIDER);
|
171
|
+
|
172
|
+
t.end();
|
173
|
+
});
|
174
|
+
|
175
|
+
t.test('when invalid request', async (t) => {
|
176
|
+
let response;
|
177
|
+
|
178
|
+
const body: Partial<IdPConfig> = Object.assign({}, config[0]);
|
179
|
+
|
180
|
+
await apiController.config(body);
|
181
|
+
|
182
|
+
// Empty body
|
183
|
+
try {
|
184
|
+
await apiController.getConfig({});
|
185
|
+
t.fail('Expecting Error.');
|
186
|
+
} catch (err: any) {
|
187
|
+
t.match(
|
188
|
+
err.message,
|
189
|
+
'Please provide `clientID` or `tenant` and `product`.'
|
190
|
+
);
|
191
|
+
}
|
192
|
+
|
193
|
+
// Invalid clientID
|
194
|
+
response = await apiController.getConfig({
|
195
|
+
clientID: 'an invalid clientID',
|
196
|
+
});
|
197
|
+
|
198
|
+
t.match(response, {});
|
199
|
+
|
200
|
+
// Invalid tenant and product combination
|
201
|
+
response = await apiController.getConfig({
|
202
|
+
tenant: 'demo.com',
|
203
|
+
product: 'desk',
|
204
|
+
});
|
205
|
+
|
206
|
+
t.match(response, {});
|
207
|
+
|
208
|
+
t.end();
|
209
|
+
});
|
210
|
+
|
211
|
+
t.end();
|
212
|
+
});
|
213
|
+
|
214
|
+
t.test('Delete the config', async (t) => {
|
215
|
+
t.test('when valid request', async (t) => {
|
216
|
+
const body: Partial<IdPConfig> = Object.assign({}, config[0]);
|
217
|
+
|
218
|
+
const client = await apiController.config(body);
|
219
|
+
|
220
|
+
await apiController.deleteConfig({
|
221
|
+
clientID: client.client_id,
|
222
|
+
clientSecret: client.client_secret,
|
223
|
+
});
|
224
|
+
|
225
|
+
const response = await apiController.getConfig({
|
226
|
+
clientID: client.client_id,
|
227
|
+
});
|
228
|
+
|
229
|
+
t.match(response, {});
|
230
|
+
|
231
|
+
t.end();
|
232
|
+
});
|
233
|
+
|
234
|
+
t.test('when invalid request', async (t) => {
|
235
|
+
let response;
|
236
|
+
|
237
|
+
const body: Partial<IdPConfig> = Object.assign({}, config[0]);
|
238
|
+
|
239
|
+
const client = await apiController.config(body);
|
240
|
+
|
241
|
+
// Empty body
|
242
|
+
try {
|
243
|
+
await apiController.deleteConfig({});
|
244
|
+
t.fail('Expecting Error.');
|
245
|
+
} catch (err: any) {
|
246
|
+
t.match(
|
247
|
+
err.message,
|
248
|
+
'Please provide `clientID` and `clientSecret` or `tenant` and `product`.'
|
249
|
+
);
|
250
|
+
}
|
251
|
+
|
252
|
+
// Invalid clientID or clientSecret
|
253
|
+
try {
|
254
|
+
await apiController.deleteConfig({
|
255
|
+
clientID: client.client_id,
|
256
|
+
clientSecret: 'invalid client secret',
|
257
|
+
});
|
258
|
+
|
259
|
+
t.fail('Expecting Error.');
|
260
|
+
} catch (err: any) {
|
261
|
+
t.match(err.message, 'clientSecret mismatch.');
|
262
|
+
}
|
263
|
+
|
264
|
+
t.end();
|
265
|
+
});
|
266
|
+
|
267
|
+
t.end();
|
268
|
+
});
|
269
|
+
|
270
|
+
t.end();
|
271
|
+
});
|
@@ -0,0 +1,30 @@
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
2
|
+
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="https://accounts.google.com/o/saml2" validUntil="2026-06-22T18:39:53.000Z">
|
3
|
+
<md:IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
4
|
+
<md:KeyDescriptor use="signing">
|
5
|
+
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
|
6
|
+
<ds:X509Data>
|
7
|
+
<ds:X509Certificate>MIIDdDCCAlygAwIBAgIGAXo6K+u/MA0GCSqGSIb3DQEBCwUAMHsxFDASBgNVBAoTC0dvb2dsZSBJ
|
8
|
+
bmMuMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MQ8wDQYDVQQDEwZHb29nbGUxGDAWBgNVBAsTD0dv
|
9
|
+
b2dsZSBGb3IgV29yazELMAkGA1UEBhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWEwHhcNMjEwNjIz
|
10
|
+
MTgzOTUzWhcNMjYwNjIyMTgzOTUzWjB7MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEWMBQGA1UEBxMN
|
11
|
+
TW91bnRhaW4gVmlldzEPMA0GA1UEAxMGR29vZ2xlMRgwFgYDVQQLEw9Hb29nbGUgRm9yIFdvcmsx
|
12
|
+
CzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A
|
13
|
+
MIIBCgKCAQEA4qZcxwPiVka9GzGdQ9LVlgVkn3A7O3HtxR6RIm5AMaL4YZziEHt2HgxLdJZyXYJw
|
14
|
+
yfT1KB2IHt+XDQBkgEpQVXuuwSPI8vhI8Jr+nr8zia3MMoy9vJF8ZG7HuWeakaKEh7tJqjYu1Cl9
|
15
|
+
a81rkYdXAFUA+gl2q+stvK26xylAUwptCJSQo0NanWzCq+k5zvX0uLmh58+W5Yv11hDTtAoW+1dH
|
16
|
+
LWUTHXPfoZINPRy5NGKJ2Onq5/D5XJRimNnUa2iYi0Yv9txp1RRq4dpB9MaVttt3iKyDo4/+8fg/
|
17
|
+
bL8BLhguiOeqcP4DEIzMuExi3bZAOu2NC7k7Qf28nA81LzP9DQIDAQABMA0GCSqGSIb3DQEBCwUA
|
18
|
+
A4IBAQARBNB3+MfmKr5WXNXXE9YwUzUGmpfbqUPXh2y2dOAkj6TzoekAsOLWB0p8oyJ5d1bFlTsx
|
19
|
+
i1OY9RuFl0tc35Jbo+ae5GfUvJmbnYGi9z8sBL55HY6x3KQNmM/ehof7ttZwvB6nwuRxAiGYG497
|
20
|
+
3tSzrqMQzEskcgX1mlCW0vks/ztCaayprDXcCUxWdP9FaiSZDEXV6PHhFZgGlRNvERsgaMDJgOsq
|
21
|
+
v6hLX10Q9CtOWzqu18PI4DcfoZ7exWcC29yWvwZzDTfHGaSG1DtUFLwiQmhVUbfd7/fmLV+/iOxV
|
22
|
+
zI0b5xSYZOJ7Kena7gd5zGVrc2ygKAFKiffiI5GLmLkv</ds:X509Certificate>
|
23
|
+
</ds:X509Data>
|
24
|
+
</ds:KeyInfo>
|
25
|
+
</md:KeyDescriptor>
|
26
|
+
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
|
27
|
+
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://accounts.google.com/o/saml2"/>
|
28
|
+
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://accounts.google.com/o/saml2"/>
|
29
|
+
</md:IDPSSODescriptor>
|
30
|
+
</md:EntityDescriptor>
|
@@ -0,0 +1 @@
|
|
1
|
+
PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4NCjxzYW1sMnA6UmVzcG9uc2UgeG1sbnM6c2FtbDJwPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6cHJvdG9jb2wiIERlc3RpbmF0aW9uPSJodHRwczovLzdlYTItMTAzLTE1My0xMDQtMTYxLm5ncm9rLmlvL3Nzby9vYXV0aC9zYW1sIiBJRD0iXzRkZmM5MjYwZDFjZTVlMDRhZTQ4ZTg4ZWJkNGNlOTY3IiBJblJlc3BvbnNlVG89Il9kYWNkMTRhZGVmMmNiMDc1NGM5NiIgSXNzdWVJbnN0YW50PSIyMDIxLTEyLTA2VDE1OjIzOjA3LjM2MFoiIFZlcnNpb249IjIuMCI+DQogICA8c2FtbDI6SXNzdWVyIHhtbG5zOnNhbWwyPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXNzZXJ0aW9uIj5odHRwczovL2FjY291bnRzLmdvb2dsZS5jb20vby9zYW1sMjwvc2FtbDI6SXNzdWVyPg0KICAgPGRzOlNpZ25hdHVyZSB4bWxuczpkcz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC8wOS94bWxkc2lnIyI+DQogICAgICA8ZHM6U2lnbmVkSW5mbz4NCiAgICAgICAgIDxkczpDYW5vbmljYWxpemF0aW9uTWV0aG9kIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS8xMC94bWwtZXhjLWMxNG4jIiAvPg0KICAgICAgICAgPGRzOlNpZ25hdHVyZU1ldGhvZCBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMDQveG1sZHNpZy1tb3JlI3JzYS1zaGEyNTYiIC8+DQogICAgICAgICA8ZHM6UmVmZXJlbmNlIFVSST0iI180ZGZjOTI2MGQxY2U1ZTA0YWU0OGU4OGViZDRjZTk2NyI+DQogICAgICAgICAgICA8ZHM6VHJhbnNmb3Jtcz4NCiAgICAgICAgICAgICAgIDxkczpUcmFuc2Zvcm0gQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwLzA5L3htbGRzaWcjZW52ZWxvcGVkLXNpZ25hdHVyZSIgLz4NCiAgICAgICAgICAgICAgIDxkczpUcmFuc2Zvcm0gQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzEwL3htbC1leGMtYzE0biMiIC8+DQogICAgICAgICAgICA8L2RzOlRyYW5zZm9ybXM+DQogICAgICAgICAgICA8ZHM6RGlnZXN0TWV0aG9kIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS8wNC94bWxlbmMjc2hhMjU2IiAvPg0KICAgICAgICAgICAgPGRzOkRpZ2VzdFZhbHVlPjI3Vy9EZTVKTlZIWkk1VTVKZGIvUi9mUXIya0pmd1VPbk0vVlRmQ1ZwQU09PC9kczpEaWdlc3RWYWx1ZT4NCiAgICAgICAgIDwvZHM6UmVmZXJlbmNlPg0KICAgICAgPC9kczpTaWduZWRJbmZvPg0KICAgICAgPGRzOlNpZ25hdHVyZVZhbHVlPm4vWCsvb0ZzK3JNU2gxMlg4dnowWlNkNlFpcU50eTY3RnFVbVNwdGllRmNoM0NScGQzZ2dpSHNwTTlMcm9MWjZVZGlsbThtdFZqTkgNCi9ob21yWDNvVEQ4UStxdzNiSllOTEptNEQvRWNmZmRDNmpSb0RJZzRYeFYxYlBVaWhQTnI4dlBFZEF2eEdwNTNiZ2MyREJsWkJpT3gNCnh4RlBSbEtnajJDWjh5SWk1R05FMUVTYms2SEtjY3g4R2dxWmtSYkVRbWtnbVMxZG1xcGl5bUpQM3orMHlaaXQyZ3dwQW5WVjNCMDUNCnZOcy8rSTFzRlZLaHNWcTc4QzZNWlZzV1pUSi80RFhadWhVSnpLNElTSU11b1RqUWFacEZMeEpBKzlhZzIvcm9OMjkwcitpcTZ5MVQNCjNHSlF1TlBPU0JVS1NpWlZVNmdwTldRRDBxckFxSWFQUFpOZnp3PT08L2RzOlNpZ25hdHVyZVZhbHVlPg0KICAgICAgPGRzOktleUluZm8+DQogICAgICAgICA8ZHM6WDUwOURhdGE+DQogICAgICAgICAgICA8ZHM6WDUwOVN1YmplY3ROYW1lPlNUPUNhbGlmb3JuaWEsQz1VUyxPVT1Hb29nbGUgRm9yIFdvcmssQ049R29vZ2xlLEw9TW91bnRhaW4gVmlldyxPPUdvb2dsZSBJbmMuPC9kczpYNTA5U3ViamVjdE5hbWU+DQogICAgICAgICAgICA8ZHM6WDUwOUNlcnRpZmljYXRlPk1JSURkRENDQWx5Z0F3SUJBZ0lHQVhvNksrdS9NQTBHQ1NxR1NJYjNEUUVCQ3dVQU1Ic3hGREFTQmdOVkJBb1RDMGR2YjJkc1pTQkoNCmJtTXVNUll3RkFZRFZRUUhFdzFOYjNWdWRHRnBiaUJXYVdWM01ROHdEUVlEVlFRREV3WkhiMjluYkdVeEdEQVdCZ05WQkFzVEQwZHYNCmIyZHNaU0JHYjNJZ1YyOXlhekVMTUFrR0ExVUVCaE1DVlZNeEV6QVJCZ05WQkFnVENrTmhiR2xtYjNKdWFXRXdIaGNOTWpFd05qSXoNCk1UZ3pPVFV6V2hjTk1qWXdOakl5TVRnek9UVXpXakI3TVJRd0VnWURWUVFLRXd0SGIyOW5iR1VnU1c1akxqRVdNQlFHQTFVRUJ4TU4NClRXOTFiblJoYVc0Z1ZtbGxkekVQTUEwR0ExVUVBeE1HUjI5dloyeGxNUmd3RmdZRFZRUUxFdzlIYjI5bmJHVWdSbTl5SUZkdmNtc3gNCkN6QUpCZ05WQkFZVEFsVlRNUk13RVFZRFZRUUlFd3BEWVd4cFptOXlibWxoTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEENCk1JSUJDZ0tDQVFFQTRxWmN4d1BpVmthOUd6R2RROUxWbGdWa24zQTdPM0h0eFI2UkltNUFNYUw0WVp6aUVIdDJIZ3hMZEpaeVhZSncNCnlmVDFLQjJJSHQrWERRQmtnRXBRVlh1dXdTUEk4dmhJOEpyK25yOHppYTNNTW95OXZKRjhaRzdIdVdlYWthS0VoN3RKcWpZdTFDbDkNCmE4MXJrWWRYQUZVQStnbDJxK3N0dksyNnh5bEFVd3B0Q0pTUW8wTmFuV3pDcStrNXp2WDB1TG1oNTgrVzVZdjExaERUdEFvVysxZEgNCkxXVVRIWFBmb1pJTlBSeTVOR0tKMk9ucTUvRDVYSlJpbU5uVWEyaVlpMFl2OXR4cDFSUnE0ZHBCOU1hVnR0dDNpS3lEbzQvKzhmZy8NCmJMOEJMaGd1aU9lcWNQNERFSXpNdUV4aTNiWkFPdTJOQzdrN1FmMjhuQTgxTHpQOURRSURBUUFCTUEwR0NTcUdTSWIzRFFFQkN3VUENCkE0SUJBUUFSQk5CMytNZm1LcjVXWE5YWEU5WXdVelVHbXBmYnFVUFhoMnkyZE9Ba2o2VHpvZWtBc09MV0IwcDhveUo1ZDFiRmxUc3gNCmkxT1k5UnVGbDB0YzM1SmJvK2FlNUdmVXZKbWJuWUdpOXo4c0JMNTVIWTZ4M0tRTm1NL2Vob2Y3dHRad3ZCNm53dVJ4QWlHWUc0OTcNCjN0U3pycU1RekVza2NnWDFtbENXMHZrcy96dENhYXlwckRYY0NVeFdkUDlGYWlTWkRFWFY2UEhoRlpnR2xSTnZFUnNnYU1ESmdPc3ENCnY2aExYMTBROUN0T1d6cXUxOFBJNERjZm9aN2V4V2NDMjl5V3Z3WnpEVGZIR2FTRzFEdFVGTHdpUW1oVlViZmQ3L2ZtTFYrL2lPeFYNCnpJMGI1eFNZWk9KN0tlbmE3Z2Q1ekdWcmMyeWdLQUZLaWZmaUk1R0xtTGt2PC9kczpYNTA5Q2VydGlmaWNhdGU+DQogICAgICAgICA8L2RzOlg1MDlEYXRhPg0KICAgICAgPC9kczpLZXlJbmZvPg0KICAgPC9kczpTaWduYXR1cmU+DQogICA8c2FtbDJwOlN0YXR1cz4NCiAgICAgIDxzYW1sMnA6U3RhdHVzQ29kZSBWYWx1ZT0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOnN0YXR1czpTdWNjZXNzIiAvPg0KICAgPC9zYW1sMnA6U3RhdHVzPg0KICAgPHNhbWwyOkFzc2VydGlvbiB4bWxuczpzYW1sMj0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmFzc2VydGlvbiIgSUQ9Il8xMWZkN2U5MDdjZmNiMTEwODM3NjI5OGM1Nzc0ZjgyNyIgSXNzdWVJbnN0YW50PSIyMDIxLTEyLTA2VDE1OjIzOjA3LjM2MFoiIFZlcnNpb249IjIuMCI+DQogICAgICA8c2FtbDI6SXNzdWVyPmh0dHBzOi8vYWNjb3VudHMuZ29vZ2xlLmNvbS9vL3NhbWwyPC9zYW1sMjpJc3N1ZXI+DQogICAgICA8c2FtbDI6U3ViamVjdD4NCiAgICAgICAgIDxzYW1sMjpOYW1lSUQgRm9ybWF0PSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoxLjE6bmFtZWlkLWZvcm1hdDplbWFpbEFkZHJlc3MiPmtpcmFuQGRlbW8uY29tPC9zYW1sMjpOYW1lSUQ+DQogICAgICAgICA8c2FtbDI6U3ViamVjdENvbmZpcm1hdGlvbiBNZXRob2Q9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDpjbTpiZWFyZXIiPg0KICAgICAgICAgICAgPHNhbWwyOlN1YmplY3RDb25maXJtYXRpb25EYXRhIEluUmVzcG9uc2VUbz0iX2RhY2QxNGFkZWYyY2IwNzU0Yzk2IiBOb3RPbk9yQWZ0ZXI9IjIwMjEtMTItMDZUMTU6Mjg6MDcuMzYwWiIgUmVjaXBpZW50PSJodHRwczovLzdlYTItMTAzLTE1My0xMDQtMTYxLm5ncm9rLmlvL3Nzby9vYXV0aC9zYW1sIiAvPg0KICAgICAgICAgPC9zYW1sMjpTdWJqZWN0Q29uZmlybWF0aW9uPg0KICAgICAgPC9zYW1sMjpTdWJqZWN0Pg0KICAgICAgPHNhbWwyOkNvbmRpdGlvbnMgTm90QmVmb3JlPSIyMDIxLTEyLTA2VDE1OjE4OjA3LjM2MFoiIE5vdE9uT3JBZnRlcj0iMjAyMS0xMi0wNlQxNToyODowNy4zNjBaIj4NCiAgICAgICAgIDxzYW1sMjpBdWRpZW5jZVJlc3RyaWN0aW9uPg0KICAgICAgICAgICAgPHNhbWwyOkF1ZGllbmNlPmh0dHBzOi8vc2FtbC5ib3h5aHEuY29tPC9zYW1sMjpBdWRpZW5jZT4NCiAgICAgICAgIDwvc2FtbDI6QXVkaWVuY2VSZXN0cmljdGlvbj4NCiAgICAgIDwvc2FtbDI6Q29uZGl0aW9ucz4NCiAgICAgIDxzYW1sMjpBdHRyaWJ1dGVTdGF0ZW1lbnQ+DQogICAgICAgICA8c2FtbDI6QXR0cmlidXRlIE5hbWU9InVzZXIuZW1haWwiPg0KICAgICAgICAgICAgPHNhbWwyOkF0dHJpYnV0ZVZhbHVlIHhtbG5zOnhzPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYSIgeG1sbnM6eHNpPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeHNpOnR5cGU9InhzOmFueVR5cGUiPmtpcmFuQGRlbW8uY29tPC9zYW1sMjpBdHRyaWJ1dGVWYWx1ZT4NCiAgICAgICAgIDwvc2FtbDI6QXR0cmlidXRlPg0KICAgICAgICAgPHNhbWwyOkF0dHJpYnV0ZSBOYW1lPSJ1c2VyLmZpcnN0TmFtZSI+DQogICAgICAgICAgICA8c2FtbDI6QXR0cmlidXRlVmFsdWUgeG1sbnM6eHM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hIiB4bWxuczp4c2k9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hLWluc3RhbmNlIiB4c2k6dHlwZT0ieHM6YW55VHlwZSI+S2lyYW48L3NhbWwyOkF0dHJpYnV0ZVZhbHVlPg0KICAgICAgICAgPC9zYW1sMjpBdHRyaWJ1dGU+DQogICAgICAgICA8c2FtbDI6QXR0cmlidXRlIE5hbWU9InVzZXIuaWQiIC8+DQogICAgICAgICA8c2FtbDI6QXR0cmlidXRlIE5hbWU9InVzZXIubGFzdE5hbWUiPg0KICAgICAgICAgICAgPHNhbWwyOkF0dHJpYnV0ZVZhbHVlIHhtbG5zOnhzPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYSIgeG1sbnM6eHNpPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeHNpOnR5cGU9InhzOmFueVR5cGUiPks8L3NhbWwyOkF0dHJpYnV0ZVZhbHVlPg0KICAgICAgICAgPC9zYW1sMjpBdHRyaWJ1dGU+DQogICAgICAgICA8c2FtbDI6QXR0cmlidXRlIE5hbWU9Im1lbWJlci1vZiIgLz4NCiAgICAgIDwvc2FtbDI6QXR0cmlidXRlU3RhdGVtZW50Pg0KICAgICAgPHNhbWwyOkF1dGhuU3RhdGVtZW50IEF1dGhuSW5zdGFudD0iMjAyMS0xMi0wNlQxNToxNzowNS4wMDBaIiBTZXNzaW9uSW5kZXg9Il8xMWZkN2U5MDdjZmNiMTEwODM3NjI5OGM1Nzc0ZjgyNyI+DQogICAgICAgICA8c2FtbDI6QXV0aG5Db250ZXh0Pg0KICAgICAgICAgICAgPHNhbWwyOkF1dGhuQ29udGV4dENsYXNzUmVmPnVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphYzpjbGFzc2VzOnVuc3BlY2lmaWVkPC9zYW1sMjpBdXRobkNvbnRleHRDbGFzc1JlZj4NCiAgICAgICAgIDwvc2FtbDI6QXV0aG5Db250ZXh0Pg0KICAgICAgPC9zYW1sMjpBdXRoblN0YXRlbWVudD4NCiAgIDwvc2FtbDI6QXNzZXJ0aW9uPg0KPC9zYW1sMnA6UmVzcG9uc2U+
|