Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[msal-node-extensions] Introducing KeyRingPersistence which uses @napi-rs/keyring and provides cross platform secure store operations #7497

Draft
wants to merge 1 commit into
base: dev
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 4 additions & 28 deletions extensions/docs/msal-node-extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,14 +73,14 @@ const pca = new PublicClientApplication({

The FilePersistenceWithDataProtection uses the Win32 CryptProtectData and CryptUnprotectData APIs. For more information on dataProtectionScope, or optionalEntropy, reference the documentation for those APIs.

#### Mac:
#### Linux / Mac:
```js
const { KeychainPersistence } = require("@azure/msal-node-extensions");
const { KeyRingPersistence } = require("@azure/msal-node-extensions");

const cachePath = "path/to/cache/file.json";
const serviceName = "test-msal-electron-service";
const accountName = "test-msal-electron-account";
const macPersistence = await KeychainPersistence.create(cachePath, serviceName, accountName);
const macPersistence = await KeyRingPersistence.create(cachePath, serviceName, accountName);
// Use the persistence object to initialize an MSAL PublicClientApplication with cachePlugin
const pca = new PublicClientApplication({
auth: {
Expand All @@ -93,34 +93,10 @@ const pca = new PublicClientApplication({

```

- cachePath is **not** where the cache will be stored. Instead, the extensions update this file with dummy data to update the file's update time, to check if the contents on the keychain should be loaded or not. It is also used as the location for the lock file.
- cachePath is **not** where the cache will be stored. Instead, the extensions update this file with dummy data to update the file's update time, to check if the contents on the secret store should be loaded or not. It is also used as the location for the lock file.
- service name under which the cache is stored the keychain.
- account name under which the cache is stored in the keychain.

#### Linux:
```js
const { LibSecretPersistence } = require("@azure/msal-node-extensions");

const cachePath = "path/to/cache/file.json";
const serviceName = "test-msal-electron-service";
const accountName = "test-msal-electron-account";
const linuxPersistence = await LibSecretPersistence.create(cachePath, serviceName, accountName);
// Use the persistence object to initialize an MSAL PublicClientApplication with cachePlugin
const pca = new PublicClientApplication({
auth: {
clientId: "CLIENT_ID_HERE",
},
cache: {
cachePlugin: new PersistenceCachePlugin(linuxPersistence);
},
});

```

- cachePath is **not** where the cache will be stored. Instead, the extensions update this file with dummy data to update the file's update time, to check if the contents on the secret service (Gnome Keyring for example) should be loaded or not. It is also used as the location for the lock file.
- service name under which the cache is stored the secret service.
- account name under which the cache is stored in the secret service.

#### All platforms
An unencrypted file persistence, which works across all platforms, is provided for convenience, although not recommended.

Expand Down
3 changes: 2 additions & 1 deletion extensions/msal-node-extensions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,10 @@
"dependencies": {
"@azure/msal-common": "14.9.0",
"@azure/msal-node-runtime": "^0.13.6-alpha.0",
"keytar": "^7.8.0"
"@napi-rs/keyring": "^1.1.6"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^28.0.2",
"@rollup/plugin-node-resolve": "^15.0.2",
"@rollup/plugin-typescript": "^11.1.0",
"@types/jest": "^29.5.1",
Expand Down
30 changes: 25 additions & 5 deletions extensions/msal-node-extensions/rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,29 @@

import { nodeResolve } from "@rollup/plugin-node-resolve";
import typescript from "@rollup/plugin-typescript";
import commonjs from '@rollup/plugin-commonjs';
import pkg from "./package.json";

const libraryHeader = `/*! ${pkg.name} v${pkg.version} ${new Date().toISOString().split("T")[0]} */`;
const useStrictHeader = "'use strict';";
const fileHeader = `${libraryHeader}\n${useStrictHeader}`;

// Native dependencies (transitive) that need to be excluded from the bundle
const nativeDependencies = [
"@napi-rs/keyring-darwin-arm64",
"@napi-rs/keyring-linux-arm64-gnu",
"@napi-rs/keyring-linux-arm64-musl",
"@napi-rs/keyring-win32-arm64-msvc",
"@napi-rs/keyring-darwin-x64",
"@napi-rs/keyring-win32-x64-msvc",
"@napi-rs/keyring-linux-x64-gnu",
"@napi-rs/keyring-linux-x64-musl",
"@napi-rs/keyring-freebsd-x64",
"@napi-rs/keyring-win32-ia32-msvc",
"@napi-rs/keyring-linux-arm-gnueabihf",
"@napi-rs/keyring-linux-riscv64-gnu"
]

export default [
{
// for cjs build
Expand All @@ -30,8 +47,8 @@ export default [
},
external: [
...Object.keys(pkg.dependencies || {}),
...Object.keys(pkg.peerDependencies || {})

...Object.keys(pkg.peerDependencies || {}),
...nativeDependencies
],
plugins: [
typescript({
Expand All @@ -40,7 +57,8 @@ export default [
}),
nodeResolve({
preferBuiltins: true
})
}),
commonjs()
]
},
{
Expand All @@ -61,7 +79,8 @@ export default [
},
external: [
...Object.keys(pkg.dependencies || {}),
...Object.keys(pkg.peerDependencies || {})
...Object.keys(pkg.peerDependencies || {}),
...nativeDependencies
],
plugins: [
typescript({
Expand All @@ -70,7 +89,8 @@ export default [
}),
nodeResolve({
preferBuiltins: true
})
}),
commonjs()
]
}
];
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,15 @@ export class PersistenceError extends Error {
return new PersistenceError("GnomeKeyringError", errorMessage);
}

/**
* Error thrown when trying to write, load, or delete data from KeyRingPersistence.
*/
static createKeyRingPersistenceError(
errorMessage: string
): PersistenceError {
return new PersistenceError("KeyRingError", errorMessage);
}

/**
* Error thrown when trying to write, load, or delete data from keychain on macOs.
*/
Expand Down
1 change: 1 addition & 0 deletions extensions/msal-node-extensions/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export { FilePersistenceWithDataProtection } from "./persistence/FilePersistence
export { DataProtectionScope } from "./persistence/DataProtectionScope";
export { KeychainPersistence } from "./persistence/KeychainPersistence";
export { LibSecretPersistence } from "./persistence/LibSecretPersistence";
export { KeyRingPersistence } from "./persistence/KeyRingPersistence";
export { IPersistence } from "./persistence/IPersistence";
export { CrossPlatformLockOptions } from "./lock/CrossPlatformLockOptions";
export { PersistenceCreator } from "./persistence/PersistenceCreator";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/*
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/

/**
* @napi-rs/keyring is a native Node.js module that provides a simple API for reading and writing passwords to the operating system's secure store.
* - Linux: The DBus-based Secret Service, the kernel keyutils, and a combo of the two.
* - FreeBSD, OpenBSD: The DBus-based Secret Service.
* - macOS, iOS: The local keychain.
* - Windows: The Windows Credential Manager.
* More info: https://github.com/hwchen/keyring-rs?tab=readme-ov-file#platforms
*/
import { Entry } from "@napi-rs/keyring";
import type { Logger, LoggerOptions } from "@azure/msal-common";
import { dirname } from "path";
import { isNodeError } from "../utils/TypeGuards.js";
import { PersistenceError } from "../error/PersistenceError.js";
import { BasePersistence } from "./BasePersistence.js";
import type { IPersistence } from "./IPersistence.js";
import { FilePersistence } from "./FilePersistence.js";

/**
* Uses reads and writes passwords to operating systems secure store
*
* serviceName: Identifier used as key for whatever value is stored
* accountName: Account under which password should be stored
*
*/
export class KeyRingPersistence
extends BasePersistence
implements IPersistence
{
private readonly entry: Entry;
private readonly filePersistence: FilePersistence;

private constructor(
filePersistence: FilePersistence,
readonly service: string,
readonly account: string
) {
super();
this.entry = new Entry(service, account);
this.filePersistence = filePersistence;
}

public static async create(
fileLocation: string,
serviceName: string,
accountName: string,
loggerOptions?: LoggerOptions
): Promise<KeyRingPersistence> {
const filePersistence = await FilePersistence.create(
fileLocation,
loggerOptions
);

return new KeyRingPersistence(
filePersistence,
serviceName,
accountName
);
}

public async save(contents: string): Promise<void> {
try {
this.entry.setPassword(contents);
} catch (e) {
if (isNodeError(e)) {
throw PersistenceError.createKeyRingPersistenceError(e.message);
}
throw e;
}

// Write dummy data to update file mtime
await this.filePersistence.save("{}");
}

public load(): Promise<string | null> {
try {
return Promise.resolve(this.entry.getPassword());
} catch (e) {
if (isNodeError(e)) {
throw PersistenceError.createKeyRingPersistenceError(e.message);
}
throw e;
}
}

public async delete(): Promise<boolean> {
try {
await this.filePersistence.delete();

return this.entry.deletePassword();
} catch (e) {
if (isNodeError(e)) {
throw PersistenceError.createKeyRingPersistenceError(e.message);
}
throw e;
}
}

reloadNecessary(lastSync: number): Promise<boolean> {
return this.filePersistence.reloadNecessary(lastSync);
}

getFilePath(): string {
return this.filePersistence.getFilePath();
}
getLogger(): Logger {
return this.filePersistence.getLogger();
}

createForPersistenceValidation(): Promise<IPersistence> {
const testCacheFileLocation = `${dirname(
this.filePersistence.getFilePath()
)}/test.cache`;
return KeyRingPersistence.create(
testCacheFileLocation,
"persistenceValidationServiceName",
"persistencValidationAccountName"
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* Licensed under the MIT License.
*/

import keytar from "keytar";
import keytar from "@napi-rs/keyring/keytar";
import { FilePersistence } from "./FilePersistence";
import { IPersistence } from "./IPersistence";
import { PersistenceError } from "../error/PersistenceError";
Expand All @@ -13,6 +13,8 @@ import { BasePersistence } from "./BasePersistence";
import { isNodeError } from "../utils/TypeGuards";

/**
* @deprecated This class is deprecated in favor of the KeyRingPersistence class.
*
* Uses reads and writes passwords to macOS keychain
*
* serviceName: Identifier used as key for whatever value is stored
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* Licensed under the MIT License.
*/

import keytar from "keytar";
import keytar from "@napi-rs/keyring/keytar";
import { FilePersistence } from "./FilePersistence";
import { IPersistence } from "./IPersistence";
import { PersistenceError } from "../error/PersistenceError";
Expand All @@ -13,6 +13,8 @@ import { BasePersistence } from "./BasePersistence";
import { isNodeError } from "../utils/TypeGuards";

/**
* @deprecated This class is deprecated in favor of the KeyRingPersistence class.
*
* Uses reads and writes passwords to Secret Service API/libsecret. Requires libsecret
* to be installed.
*
Expand Down
Loading
Loading