Skip to content

Commit

Permalink
feat: Better support for MS Azure auth and re-using a MSAL client fro…
Browse files Browse the repository at this point in the history
…m Azure Request API
  • Loading branch information
nklomp committed Jul 18, 2023
1 parent 569b213 commit 61bdfaf
Show file tree
Hide file tree
Showing 10 changed files with 872 additions and 115 deletions.
6 changes: 3 additions & 3 deletions packages/ms-authenticator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

---

**Warning: This package still is in every early development. Breaking changes without notice will happen at this point!**
**Warning: This package still is in early development. Breaking changes without notice will happen at this point!**

---

Expand All @@ -19,11 +19,11 @@ A SSI-SDK plugin to authenticate using the Microsoft Authentication Library (MSA
### Installation

```shell
yarn add @sphereon/ssi-sdk.ms-authenticator
pnpm add @sphereon/ssi-sdk.ms-authenticator
```

### Build

```shell
yarn build
pnpm build
```
8 changes: 4 additions & 4 deletions packages/ms-authenticator/__tests__/authenticators.test.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import { ClientCredentialAuthenticator, UsernamePasswordAuthenticator } from '../src'
import { getMSClientCredentialAccessToken, UsernamePasswordAuthenticator } from '../src'
import * as process from 'process'
jest.setTimeout(100000)

describe('@sphereon/ssi-sdk.ms-authenticator', () => {
it('should authenticate using clientCredential', async () => {
return await expect(
ClientCredentialAuthenticator({
getMSClientCredentialAccessToken({
azClientId: process.env.SPHEREON_SSI_MSAL_CLIENT_ID || 'client_id',
azClientSecret: process.env.SPHEREON_SSI_MSAL_CLIENT_SECRET || 'client_secret',
azTenantId: process.env.SPHEREON_SSI_MSAL_TENANT_ID || 'tenant_id',
credentialManifestUrl:
'https://beta.eu.did.msidentity.com/v1.0/e2a42b2f-7460-4499-afc2-425315ef058a/verifiableCredential/contracts/VerifiedCredentialExpert2',
})
).resolves.not.toBeNull()
).resolves.toBeDefined()
})

it('should authenticate using usernamePassword', async () => {
Expand All @@ -24,6 +24,6 @@ describe('@sphereon/ssi-sdk.ms-authenticator', () => {
username: process.env.SPHEREON_SSI_MSAL_USERNAME || 'username',
password: process.env.SPHEREON_SSI_MSAL_PASSWORD || 'password',
})
).resolves.not.toBeNull()
).resolves.toBeDefined()
})
})
16 changes: 10 additions & 6 deletions packages/ms-authenticator/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,20 @@
"build:clean": "tsc --build --clean && tsc --build"
},
"dependencies": {
"@azure/msal-common": "^11.0.0",
"@azure/msal-node": "^1.16.0",
"@veramo/core": "4.2.0",
"@azure/msal-common": "^13.2.0",
"@azure/msal-node": "^1.18.0",
"object-hash": "^3.0.0",
"cross-fetch": "^3.1.5"
},
"devDependencies": {
"@types/jest": "^27.5.2",
"jest": "^27.5.1",
"prettier": "^2.4.1",
"ts-jest": "^27.1.5"
"@types/object-hash": "^3.0.2",
"jest": "^29.6.1",
"prettier": "^2.8.8",
"ts-jest": "^29.1.1"
},
"engines": {
"node": ">= 16.0"
},
"files": [
"dist/**/*",
Expand Down
128 changes: 86 additions & 42 deletions packages/ms-authenticator/src/authenticators/MsAuthenticator.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,25 @@
import { ConfidentialClientApplication, LogLevel, PublicClientApplication, UsernamePasswordRequest } from '@azure/msal-node'
import { IMsAuthenticationClientCredentialArgs, IMsAuthenticationUsernamePasswordArgs } from '../index'
import {
AuthenticationResult,
ConfidentialClientApplication,
Configuration,
LogLevel,
NodeAuthOptions,
PublicClientApplication,
UsernamePasswordRequest,
} from '@azure/msal-node'
import { fetch } from 'cross-fetch'
import { IMSClientCredentialAuthInfo, IMsAuthenticationClientCredentialArgs, IMsAuthenticationUsernamePasswordArgs } from '../index'

import hash from 'object-hash'

const EU = 'EU'

const HTTP_METHOD_GET = 'GET'

// Event though there are many regions, MS has only 2 identity host names (EU and NONE_EU)
// Event though there are many regions, MS has only 2 DID identity host names (EU and NON_EU)
// https://docs.microsoft.com/en-us/azure/active-directory/verifiable-credentials/whats-new#are-there-any-changes-to-the-way-that-we-use-the-request-api-as-a-result-of-this-move
export const MS_IDENTITY_HOST_NAME_NONE_EU = 'https://beta.did.msidentity.com/v1.0/'
export const MS_IDENTITY_HOST_NAME_EU = 'https://beta.eu.did.msidentity.com/v1.0/'
export const MS_DID_ENDPOINT_NON_EU = 'https://beta.did.msidentity.com/v1.0/'
export const MS_DID_ENDPOINT_EU = 'https://beta.eu.did.msidentity.com/v1.0/'
const MS_LOGIN_PREFIX = 'https://login.microsoftonline.com/'
const MS_LOGIN_OPENID_CONFIG_POSTFIX = '/v2.0/.well-known/openid-configuration'
const MS_CLIENT_CREDENTIAL_DEFAULT_SCOPE = '3db474b9-6a0c-4840-96ac-1fceb342124f/.default'
Expand All @@ -18,71 +28,96 @@ const ERROR_CREDENTIAL_MANIFEST_REGION = `Error in config file. CredentialManife
const ERROR_ACQUIRE_ACCESS_TOKEN_FOR_CLIENT = 'Could not acquire verifiableCredentials to access your Azure Key Vault:\n'
const ERROR_FAILED_AUTHENTICATION = 'failed to authenticate: '

async function getClientRegion(azTenantId: string): Promise<string> {
let region = EU
await fetch(MS_LOGIN_PREFIX + azTenantId + MS_LOGIN_OPENID_CONFIG_POSTFIX, { method: HTTP_METHOD_GET })
// todo: This is a pretty heavy operation. Getting all the OIDC discovery data from a fetch only to return the region. Probably wise to add some caching and refactor so we can do more with the other OIDC info as well
export async function getMSOpenIDClientRegion(azTenantId: string): Promise<string> {
return fetch(MS_LOGIN_PREFIX + azTenantId + MS_LOGIN_OPENID_CONFIG_POSTFIX, { method: HTTP_METHOD_GET })
.then((res) => res.json())
.then(async (resp) => {
region = resp.tenant_region_scope
return resp.tenant_region_scope ?? EU
})
return region
}

export async function checkMsIdentityHostname(authenticationArgs: IMsAuthenticationClientCredentialArgs): Promise<string> {
const region = authenticationArgs.region ? authenticationArgs.region : await getClientRegion(authenticationArgs.azTenantId)
const msIdentityHostName = region === EU ? MS_IDENTITY_HOST_NAME_EU : MS_IDENTITY_HOST_NAME_NONE_EU
export async function getEntraDIDEndpoint(opts: { region?: string; azTenantId: string }) {
const region = opts?.region ?? (await getMSOpenIDClientRegion(opts.azTenantId))
return region === EU ? MS_DID_ENDPOINT_EU : MS_DID_ENDPOINT_NON_EU
}

export async function assertEntraCredentialManifestUrlInCorrectRegion(authenticationArgs: IMsAuthenticationClientCredentialArgs): Promise<string> {
const msDIDEndpoint = await getEntraDIDEndpoint(authenticationArgs)
// Check that the Credential Manifest URL is in the same tenant Region and throw an error if it's not
if (!authenticationArgs.credentialManifestUrl.startsWith(msIdentityHostName)) {
throw new Error(ERROR_CREDENTIAL_MANIFEST_REGION + msIdentityHostName + `. value: ${authenticationArgs.credentialManifestUrl}`)
if (!authenticationArgs.credentialManifestUrl?.startsWith(msDIDEndpoint)) {
throw new Error(ERROR_CREDENTIAL_MANIFEST_REGION + msDIDEndpoint + `. value: ${authenticationArgs.credentialManifestUrl}`)
}
return msIdentityHostName
return msDIDEndpoint
}

/**
* necessary fields are:
* azClientId: clientId of the application you're trying to login
* azClientSecret: secret of the application you're trying to login
* azTenantId: your MS Azure tenantId
* optional fields:
* credentialManifest: address of your credential manifest. usually in following format:
* https://beta.eu.did.msidentity.com/v1.0/<tenant_id>/verifiableCredential/contracts/<verifiable_credential_schema>
* @param authenticationArgs
* @constructor
*/
export async function ClientCredentialAuthenticator(authenticationArgs: IMsAuthenticationClientCredentialArgs): Promise<string> {
const msalConfig = {
auth: {
clientId: authenticationArgs.azClientId,
authority: authenticationArgs.authority ? authenticationArgs.authority : MS_LOGIN_PREFIX + authenticationArgs.azTenantId,
clientSecret: authenticationArgs.azClientSecret,
},
system: {
loggerOptions: {
piiLoggingEnabled: authenticationArgs.piiLoggingEnabled ? authenticationArgs.piiLoggingEnabled : false,
logLevel: authenticationArgs.logLevel ? authenticationArgs.logLevel : LogLevel.Verbose,
},
},
export async function getMSClientCredentialAccessToken(
authenticationArgs: IMsAuthenticationClientCredentialArgs,
opts?: {
confidentialClient?: ConfidentialClientApplication
}
): Promise<AuthenticationResult> {
const confidentialClient =
opts?.confidentialClient ?? (await newMSClientCredentialAuthenticator(authenticationArgs).then((cca) => cca.confidentialClient))
if (!confidentialClient) {
throw Error('No Credential Client Authenticator could be constructed')
}
if (authenticationArgs?.credentialManifestUrl) {
await assertEntraCredentialManifestUrlInCorrectRegion(authenticationArgs)
}

const cca = new ConfidentialClientApplication(msalConfig)
const msalClientCredentialRequest = {
scopes: authenticationArgs.scopes ? authenticationArgs.scopes : [MS_CLIENT_CREDENTIAL_DEFAULT_SCOPE],
skipCache: authenticationArgs.skipCache ? authenticationArgs.skipCache : false,
scopes: authenticationArgs.scopes ?? (authenticationArgs?.credentialManifestUrl ? [MS_CLIENT_CREDENTIAL_DEFAULT_SCOPE] : []),
skipCache: authenticationArgs.skipCache ?? false,
}

checkMsIdentityHostname(authenticationArgs)

// get the Access Token
try {
const result = await cca.acquireTokenByClientCredential(msalClientCredentialRequest)
if (result && result.accessToken) {
return result.accessToken
const result = await confidentialClient.acquireTokenByClientCredential(msalClientCredentialRequest)
if (result) {
return result
}
} catch (err) {
throw {
error: ERROR_ACQUIRE_ACCESS_TOKEN_FOR_CLIENT + err,
}
}
return ''
throw {
error: ERROR_ACQUIRE_ACCESS_TOKEN_FOR_CLIENT,
}
}

export async function newMSClientCredentialAuthenticator(
authenticationArgs: IMsAuthenticationClientCredentialArgs
): Promise<IMSClientCredentialAuthInfo> {
const didEndpoint = authenticationArgs?.credentialManifestUrl
? await assertEntraCredentialManifestUrlInCorrectRegion(authenticationArgs)
: undefined
const auth = authOptions(authenticationArgs)
const id = hash(auth)
const msalConfig: Configuration = {
auth,
system: {
loggerOptions: {
piiLoggingEnabled: authenticationArgs.piiLoggingEnabled ? authenticationArgs.piiLoggingEnabled : false,
logLevel: authenticationArgs.logLevel ? authenticationArgs.logLevel : LogLevel.Verbose,
},
},
}
const confidentialClientApp = new ConfidentialClientApplication(msalConfig)

return { confidentialClient: confidentialClientApp, msalConfig, authenticationArgs, didEndpoint, id }
}

/**
Expand All @@ -92,10 +127,7 @@ export async function ClientCredentialAuthenticator(authenticationArgs: IMsAuthe
*/
export async function UsernamePasswordAuthenticator(authenticationArgs: IMsAuthenticationUsernamePasswordArgs): Promise<string> {
const msalConfig = {
auth: {
clientId: authenticationArgs.azClientId,
authority: authenticationArgs.authority ? authenticationArgs.authority : MS_LOGIN_PREFIX + authenticationArgs.azTenantId,
},
auth: authOptions(authenticationArgs),
}
const pca = new PublicClientApplication(msalConfig)
return await pca
Expand All @@ -107,3 +139,15 @@ export async function UsernamePasswordAuthenticator(authenticationArgs: IMsAuthe
throw new Error(ERROR_FAILED_AUTHENTICATION + error)
})
}

function authOptions(authenticationArgs: IMsAuthenticationClientCredentialArgs | IMsAuthenticationUsernamePasswordArgs): NodeAuthOptions {
return {
clientId: authenticationArgs.azClientId,
authority: authenticationArgs.authority ? authenticationArgs.authority : MS_LOGIN_PREFIX + authenticationArgs.azTenantId,
...(authenticationArgs && 'azClientSecret' in authenticationArgs && { clientSecret: authenticationArgs.azClientSecret }),
}
}

export function determineMSAuthId(authenticationArgs: IMsAuthenticationClientCredentialArgs | IMsAuthenticationUsernamePasswordArgs): string {
return hash(authOptions(authenticationArgs))
}
15 changes: 9 additions & 6 deletions packages/ms-authenticator/src/types/IMsAuthenticator.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import { IAgentContext } from '@veramo/core'
import { LogLevel } from '@azure/msal-node'

import { ConfidentialClientApplication, Configuration, LogLevel } from '@azure/msal-node'
/**
* azClientId: clientId of the application you're trying to login
* azClientSecret: secret of the application you're trying to login
Expand All @@ -26,7 +24,7 @@ export interface IMsAuthenticationClientCredentialArgs {
azClientId: string
azTenantId: string
azClientSecret: string
credentialManifestUrl: string
credentialManifestUrl?: string
authority?: string
region?: string
scopes?: string[]
Expand Down Expand Up @@ -54,5 +52,10 @@ export interface IMsAuthenticationUsernamePasswordArgs {
authority?: string
}

export type IRequiredContext = IAgentContext<Record<string, never>>
export type IMsAuthenticationResponse = String
export interface IMSClientCredentialAuthInfo {
id: string
confidentialClient: ConfidentialClientApplication
msalConfig: Configuration
authenticationArgs: IMsAuthenticationClientCredentialArgs
didEndpoint?: string
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export default (testContext: { getAgent: () => ConfiguredAgent; setup: () => Pro
jest.mock('@sphereon/ssi-sdk.ms-authenticator', () => {
return {
ClientCredentialAuthenticator: jest.fn().mockResolvedValue('ey...'),
checkMsIdentityHostname: jest.fn().mockResolvedValue(MsAuthenticator.MS_IDENTITY_HOST_NAME_EU),
checkMsIdentityHostname: jest.fn().mockResolvedValue(MsAuthenticator.MS_DID_ENDPOINT_EU),
}
})
await testContext.setup()
Expand Down
14 changes: 7 additions & 7 deletions packages/ms-request-api/src/IssuerUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ export async function fetchIssuanceRequestMs(
accessToken: string,
msIdentityHostName: string
): Promise<IIssueRequestResponse> {
var client_api_request_endpoint = `${msIdentityHostName}${issuanceInfo.authenticationInfo.azTenantId}/verifiablecredentials/request`
const requestEndpoint = `${msIdentityHostName}${issuanceInfo.authenticationInfo.azTenantId}/verifiablecredentials/request`

var payload = JSON.stringify(issuanceInfo.issuanceConfig)
const payload = JSON.stringify(issuanceInfo.issuanceConfig)
const fetchOptions = {
method: 'POST',
body: payload,
Expand All @@ -18,15 +18,15 @@ export async function fetchIssuanceRequestMs(
Authorization: `Bearer ${accessToken}`,
},
}
const response = await fetch(client_api_request_endpoint, fetchOptions)
const response = await fetch(requestEndpoint, fetchOptions)
return await response.json()
}

export function generatePin(digits: number) {
var add = 1,
max = 12 - add
const add = 1
let max = 12 - add
max = Math.pow(10, digits + add)
var min = max / 10 // Math.pow(10, n) basically
var number = Math.floor(Math.random() * (max - min + 1)) + min
const min = max / 10 // Math.pow(10, n) basically
const number = Math.floor(Math.random() * (max - min + 1)) + min
return ('' + number).substring(add)
}
Loading

0 comments on commit 61bdfaf

Please sign in to comment.