@microsoft/useragent-sdk
Version:
SDK for building decentralized identity wallets and enterprise agents.
609 lines (578 loc) • 21.7 kB
text/typescript
import { UserAgentSession, UserAgentOptions, EcPrivateKey } from "../../src";
import Identifier from '../../src/Identifier'
import TestResolver from "../resolvers/TestResolver";
import { OIDCRequestOptions } from "../../src/userAgentSession/UserAgentSession";
import OIDCAuthenticationRequest from "../../src/crypto/protocols/did/requests/OIDCAuthenticationRequest";
import OIDCAuthenticationResponse from '../../src/crypto/protocols/did/responses/OIDCAuthenticationResponse';
import IRequestPrompt, { IPermissionRequestPrompt } from "../../src/userAgentSession/oidc/requests/IRequestPrompt";
import IScopeDefinition from "../../src/userAgentSession/oidc/requests/IScopeDefinition";
import * as nodeFetch from 'node-fetch';
import KeyContainer from "../../src/crypto/keys/KeyContainer";
describe('UserAgentSession', () => {
let challengerSession: UserAgentSession;
let responderSession: UserAgentSession;
let challenger: Identifier;
let responder: Identifier;
let challengerResolver = new TestResolver();
let responderResolver = new TestResolver();
beforeAll(async () => {
challenger = new Identifier('did:test:challenger', new UserAgentOptions());
responder = new Identifier('did:test:responder', new UserAgentOptions());
});
beforeEach(async () => {
challengerSession = new UserAgentSession(challenger, 'did:ion-did:ion-ES256K-sig', challengerResolver);
responderSession = new UserAgentSession(responder, 'did:ion-did:ion-ES256K-sig', responderResolver);
});
describe('signRequest', () => {
const redirectUrl = `localhost/${Math.round(Math.random() * 255).toString(16)}`;
let defaultOIDC: OIDCAuthenticationRequest = {
client_id: redirectUrl,
iss: 'did:test:challenger',
nonce: 'Will Be Set In The Test',
response_mode: "form_post",
response_type: "id_token",
scope: 'openid did_authn',
registration: {
id_token_signed_response_alg: [ 'EdDSA' ]
}
};
let testParams: {
should: string,
options?: OIDCRequestOptions,
expect: OIDCAuthenticationRequest
}[] = [
{
should: 'create a simple id_token request',
expect: defaultOIDC
},
{
should: 'include state',
options: {
state: 'alphabet soup'
},
expect: Object.assign({}, defaultOIDC,
{
state: 'alphabet soup'
})
},
{
should: 'encode the manifest',
options: {
manifest: {
client_name: 'alice',
logo_uri: 'localhost/favicon.ico',
}
},
expect: Object.assign({}, defaultOIDC, {
registration: JSON.stringify({
client_name: 'alice',
logo_uri: 'localhost/favicon.ico',
})
})
},
{
should: 'include additonal scopes',
options: {
scopes: [
{
'localhost/test.all': null,
}
]
},
expect: Object.assign({}, defaultOIDC, {
scope: 'openid did_authn ' + Buffer.from(JSON.stringify({
'localhost/test.all': null,
})).toString('base64')
})
},
{
should: 'support multiple scope definitions',
options: {
scopes: [
{
'localhost/test.all': {essential: false},
},
{
'localhost/test.all': {value: 'sure'},
}
]
},
expect: Object.assign({}, defaultOIDC, {
scope: 'openid did_authn ' + Buffer.from(JSON.stringify({
'localhost/test.all': {essential: false},
})).toString('base64') + ' ' + Buffer.from(JSON.stringify({
'localhost/test.all': {value: 'sure'},
})).toString('base64')
})
},
// DISABLED WHILE CLAIMS THROW
// {
// should: 'include claim requests',
// options: {
// claimRequests: {
// credential: [
// 'PassTest'
// ]
// }
// },
// expect: Object.assign({}, defaultOIDC,
// {
// claims: {
// id_token: {
// credential: [
// 'PassTest'
// ]
// }
// }
// })
// }
];
testParams.forEach((testCase) => {
it(`should ${testCase.should}`, async () => {
const nonce = Math.round(Math.random() * Number.MAX_SAFE_INTEGER).toString(16);
const signSpy = spyOn(challenger, 'sign').and.callFake((request: OIDCAuthenticationRequest) => {
testCase.expect.nonce = nonce;
expect(request).toEqual(testCase.expect);
return Promise.resolve('');
});
await challengerSession.signRequest(redirectUrl, nonce, testCase.options);
expect(signSpy).toHaveBeenCalled();
});
});
});
describe('verifyAndHydrateRequest', () => {
const redirectUrl = `http://localhost/${Math.round(Math.random() * 255).toString(16)}`;
const defaultPrompt: IRequestPrompt = {
client_id: redirectUrl,
host: 'localhost',
iss: 'did:test:challenger',
nonce: 'Will Be Set In The Test',
response_mode: 'form_post',
response_type: 'id_token',
scope: 'openid did_authn'
};
const noScopePrompt: any = {
client_id: redirectUrl,
host: 'localhost',
iss: 'did:test:challenger',
nonce: 'Will Be Set In The Test',
response_mode: 'form_post',
response_type: 'id_token',
}
let testParams: {
should: string,
request?: OIDCRequestOptions,
resolvedScopes?: {[scopeUrls: string]: IScopeDefinition},
expected: Partial<IRequestPrompt>
}[] = [{
should: 'create a basic sign in prompt',
expected: Object.assign({}, defaultPrompt)
},
{
should: 'populate fields in the manifest',
request: {
manifest: {
client_name: 'Test Client 123',
logo_uri: 'http://localhost/icon',
client_uri: 'http://localhost/index.html',
policy_uri: 'http://localhost/cookies',
tos_uri: 'http://localhost/tos'
}
},
expected: Object.assign({}, defaultPrompt, {
name: 'Test Client 123',
logoUrl: 'http://localhost/icon',
homepage: 'http://localhost/index.html',
dataUsePolicy: 'http://localhost/cookies',
termsOfService: 'http://localhost/tos'
})
},
{
should: 'ignore unfilled manifest fields',
request: {
manifest: {
client_name: 'Test Client 123'
}
},
expected: Object.assign({}, defaultPrompt, {
name: 'Test Client 123',
logoUrl: undefined,
homepage: undefined,
dataUsePolicy: undefined,
termsOfService: undefined
})
},
// DISABLED WHILE CLAIMS THROW
// {
// should: 'include credential claims',
// request: {
// claimRequests: {
// credential: {
// TestStatus: null
// }
// }
// },
// expected: Object.assign({}, defaultPrompt, {
// credentialsRequested: ['TestStatus']
// })
// },
{
should: 'retrieve permission scopes',
request: {
scopes: [{
'http://localhost/testStatus.read': { essential: true },
}]
},
resolvedScopes: {
"http://localhost/testStatus.read": {
value: "http://localhost/testStatus.read",
resourceBundle: {
name: "Test Status",
description: "Read access to all test statuses",
icon_uri: "http://localhost/icon.ico"
},
access: [
{
resource_type: 'https://schema.org/TestStatus',
allow: '-R--'
}
]
}
},
expected: Object.assign({}, noScopePrompt, {
identityHubPermissionsRequested: [
{
required: true,
name: "Test Status",
description: "Read access to all test statuses",
iconUrl: 'http://localhost/icon.ico',
grants: [
{
owner: 'did:test:responder',
grantee: 'did:test:challenger',
allow: '-R--',
context: 'https://schema.org',
type: 'TestStatus'
}
]
}
]
})
},
{
should: 'handle multiple different permission scopes',
request: {
scopes: [{
'http://localhost/testStatus.read': { essential: true },
'http://localhost/testStatus.write': null,
}]
},
resolvedScopes: {
"http://localhost/testStatus.read": {
value: "http://localhost/testStatus.read",
resourceBundle: {
name: "Test Status",
description: "Read access to all test statuses",
icon_uri: "http://localhost/icon.ico"
},
access: [
{
resource_type: 'https://schema.org/TestStatus',
allow: '-R--'
}
]
},
"http://localhost/testStatus.write": {
value: "http://localhost/testStatus.write",
resourceBundle: {
name: "Test Status",
description: "Write access to all test statuses",
icon_uri: "http://localhost/icon.ico"
},
access: [
{
resource_type: 'https://schema.org/TestStatus',
allow: 'C-UD'
}
]
}
},
expected: Object.assign({}, noScopePrompt, {
identityHubPermissionsRequested: [
{
required: true,
name: "Test Status",
description: "Read access to all test statuses",
iconUrl: 'http://localhost/icon.ico',
grants: [
{
owner: 'did:test:responder',
grantee: 'did:test:challenger',
allow: '-R--',
context: 'https://schema.org',
type: 'TestStatus'
}
]
},
{
required: false,
name: "Test Status",
description: "Write access to all test statuses",
iconUrl: 'http://localhost/icon.ico',
grants: [
{
owner: 'did:test:responder',
grantee: 'did:test:challenger',
allow: 'C-UD',
context: 'https://schema.org',
type: 'TestStatus'
}
]
}
]
})
}];
testParams.forEach((testCase) => {
it(`should ${testCase.should}`, async () => {
const nonce = Math.round(Math.random() * Number.MAX_SAFE_INTEGER).toString(16);
// utilize the sign request function, since we've got it...
let formedRequest: OIDCAuthenticationRequest;
spyOn(challengerSession['sender'], 'sign').and.callFake((request: OIDCAuthenticationRequest) => {
formedRequest = request;
return Promise.resolve('');
});
await challengerSession.signRequest(redirectUrl, nonce, testCase.request);
const encryptedStub = Math.round(Math.random() * Number.MAX_SAFE_INTEGER).toString(32);
const verifySpy = spyOn(responderSession, 'verify').and.callFake(async (request: string) => {
expect(request).toEqual(encryptedStub);
return Promise.resolve(formedRequest);
});
const fetchSpy = spyOn(nodeFetch, 'default').and.callFake((url: any): any => {
if (testCase.resolvedScopes && url in testCase.resolvedScopes) {
return Promise.resolve({
status: 200,
text: () => {
return JSON.stringify(testCase.resolvedScopes![url]);
}
});
} else {
fail(`Attempted superfluous resolve to url: ${url}`);
}
});
let expected: IRequestPrompt = Object.assign({}, formedRequest!, testCase.expected, { nonce });
let actual = await responderSession.verifyAndHydrateRequest(encryptedStub);
expect(verifySpy).toHaveBeenCalled();
if (testCase.resolvedScopes) {
expect(fetchSpy).toHaveBeenCalled();
};
expect(actual).toEqual(expected);
});
});
it('should not include host if the redirect is a deeplink', async () => {
const nonce = Math.round(Math.random() * Number.MAX_SAFE_INTEGER).toString(16);
// utilize the sign request function, since we've got it...
let formedRequest: OIDCAuthenticationRequest;
spyOn(challengerSession['sender'], 'sign').and.callFake((request: OIDCAuthenticationRequest) => {
formedRequest = request;
return Promise.resolve('');
});
await challengerSession.signRequest('myapp://response', nonce);
const verifySpy = spyOn(responderSession, 'verify').and.returnValue(Promise.resolve(formedRequest!));
let actual = await responderSession.verifyAndHydrateRequest('doesnt Matter');
expect(verifySpy).toHaveBeenCalled();
expect(actual.host).toBeUndefined();
});
it('should throw if the scope definition failed to be retrieved', async () => {
const nonce = Math.round(Math.random() * Number.MAX_SAFE_INTEGER).toString(16);
// utilize the sign request function, since we've got it...
let formedRequest: OIDCAuthenticationRequest;
spyOn(challengerSession['sender'], 'sign').and.callFake((request: OIDCAuthenticationRequest) => {
formedRequest = request;
return Promise.resolve('');
});
await challengerSession.signRequest(redirectUrl, nonce, {
scopes: [
{
'https://169.254.0.0/404': null,
}
]
});
const verifySpy = spyOn(responderSession, 'verify').and.returnValue(Promise.resolve(formedRequest!));
const fetchSpy = spyOn(nodeFetch, 'default').and.returnValue(<any> Promise.resolve({
status: 404,
text: () => 'Not Found'
}));
try {
await responderSession.verifyAndHydrateRequest('doesnt Matter');
fail('expected to throw');
} catch (error) {
expect(error.message).toContain('https://169.254.0.0/404');
} finally {
expect(verifySpy).toHaveBeenCalled();
expect(fetchSpy).toHaveBeenCalled();
}
});
it('should throw if the scope definitions resource_type is incorrectly formatted', async () => {
const nonce = Math.round(Math.random() * Number.MAX_SAFE_INTEGER).toString(16);
// utilize the sign request function, since we've got it...
let formedRequest: OIDCAuthenticationRequest;
spyOn(challengerSession['sender'], 'sign').and.callFake((request: OIDCAuthenticationRequest) => {
formedRequest = request;
return Promise.resolve('');
});
await challengerSession.signRequest(redirectUrl, nonce, {
scopes: [
{
'https://localhost/ok': null,
}
]
});
const verifySpy = spyOn(responderSession, 'verify').and.returnValue(Promise.resolve(formedRequest!));
const fetchSpy = spyOn(nodeFetch, 'default').and.returnValue(<any> Promise.resolve({
status: 200,
text: () => JSON.stringify({
value: "http://localhost/ok",
resourceBundle: {
name: "Test",
description: "should fail"
},
access: [
{
resource_type: 'https://schema.org',
allow: '----'
}
]
})
}));
try {
await responderSession.verifyAndHydrateRequest('doesnt Matter');
fail('expected to throw');
} catch (error) {
expect(error.message).toContain('resource_type');
} finally {
expect(verifySpy).toHaveBeenCalled();
expect(fetchSpy).toHaveBeenCalled();
}
});
});
describe('sendResponse', () => {
const redirectUrl = `http://localhost/${Math.round(Math.random() * 255).toString(16)}`;
const defaultResponse: OIDCAuthenticationResponse = {
iss: 'https://self-issued.me',
aud: redirectUrl,
sub: 'thumbprint',
nonce: 'nonce',
did_comm: {
did: 'did:test:responder'
},
sub_jwk: {},
exp: 0,
iat: 0
};
const testCases: {
should: string,
request?: OIDCRequestOptions,
claims?: any,
grants?: IPermissionRequestPrompt[],
expected: OIDCAuthenticationResponse
}[] = [
{
should: 'send a simple id_token back',
expected: Object.assign({}, defaultResponse)
},
{
should: 'send permission grants',
grants: [
{
name: 'Test',
description: 'Read test results',
required: true,
grants: [
{
allow: '-R--',
context: 'schema.org/',
owner: 'did:test:responder',
grantee: 'did:test:challenger',
type: 'test',
}
]
}
],
expected: Object.assign({}, defaultResponse)
}
];
testCases.forEach((testCase) => {
it(`should ${testCase.should}`, async () => {
const nonce = Math.round(Math.random() * Number.MAX_SAFE_INTEGER).toString(16);
// utilize the sign request function, since we've got it...
let formedRequest: OIDCAuthenticationRequest;
spyOn(challengerSession['sender'], 'sign').and.callFake((request: OIDCAuthenticationRequest) => {
formedRequest = request;
return Promise.resolve('');
});
await challengerSession.signRequest(redirectUrl, nonce, testCase.request);
// mock the entire HubInterface to avoid sending data
let grantsSubmitted = 0;
responderSession['permissions'] = <any> {
addObject: (commit: any) => {
if (testCase.grants) {
let grant = <IPermissionRequestPrompt> commit;
grant.grants.forEach((permissionGrant) => {
expect(permissionGrant.owner).toEqual('did:test:responder');
expect(permissionGrant.grantee).toEqual('did:test:challenger');
if (!testCase.grants!.some((grantBundle) => {
return grantBundle.grants.some((expectedGrant) => {
return permissionGrant.allow === expectedGrant.allow && permissionGrant.context === expectedGrant.context && permissionGrant.type === expectedGrant.type;
});
})) {
fail(`Unexpected Permission Grant commited: ${grant}`);
} else {
grantsSubmitted++;
}
})
} else {
fail('Attempted to Commit an object when no Permission Grants were passed')
}
return Promise.resolve({
getRevisions: () => { return 'permissionGrantId'; }
});
}
}
spyOn(responderSession['sender']['options']!['keyStore'], 'get').and.returnValue(Promise.resolve(new KeyContainer(new EcPrivateKey({
d: Math.round(Math.random() * Number.MAX_SAFE_INTEGER),
x: Math.round(Math.random() * Number.MAX_SAFE_INTEGER),
y: Math.round(Math.random() * Number.MAX_SAFE_INTEGER)
}).getPublicKey())));
const signCode = Math.round(Math.random() * Number.MAX_SAFE_INTEGER).toString(16);
const signSpy = spyOn(responderSession['sender'], 'sign').and.callFake((response: OIDCAuthenticationResponse) => {
expect(response).toEqual(Object.assign(testCase.expected, {
nonce,
exp: response.exp,
iat: response.iat,
sub: response.sub,
sub_jwk: response.sub_jwk,
}));
return Promise.resolve(signCode);
});
const fetchSpy = spyOn(nodeFetch, 'default').and.callFake((url: nodeFetch.Request | string, options?: nodeFetch.RequestInit) => {
expect(url).toEqual(redirectUrl);
expect(options!.method!.toLowerCase()).toEqual('post');
expect(options!.body).toEqual(`id_token=${signCode}`);
return Promise.resolve(<any> {
status: 204,
text: () => 'Test passed'
});
});
await responderSession.sendResponse(formedRequest!, testCase.grants, testCase.claims)
expect(signSpy).toHaveBeenCalled();
expect(fetchSpy).toHaveBeenCalled();
if (testCase.grants) {
const totalGrantsExpected = testCase.grants.reduce((total: number, grantPrompt: IPermissionRequestPrompt) => {
return total + grantPrompt.grants.length;
}, 0);
expect(grantsSubmitted).toEqual(totalGrantsExpected);
}
});
});
});
});