diff --git a/package-lock.json b/package-lock.json index fecdc6a5..3ab7da11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4727,8 +4727,7 @@ }, "ansi-regex": { "version": "2.1.1", - "bundled": true, - "optional": true + "bundled": true }, "aproba": { "version": "1.2.0", @@ -4746,13 +4745,11 @@ }, "balanced-match": { "version": "1.0.0", - "bundled": true, - "optional": true + "bundled": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, - "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -4765,18 +4762,15 @@ }, "code-point-at": { "version": "1.1.0", - "bundled": true, - "optional": true + "bundled": true }, "concat-map": { "version": "0.0.1", - "bundled": true, - "optional": true + "bundled": true }, "console-control-strings": { "version": "1.1.0", - "bundled": true, - "optional": true + "bundled": true }, "core-util-is": { "version": "1.0.2", @@ -4879,8 +4873,7 @@ }, "inherits": { "version": "2.0.3", - "bundled": true, - "optional": true + "bundled": true }, "ini": { "version": "1.3.5", @@ -4890,7 +4883,6 @@ "is-fullwidth-code-point": { "version": "1.0.0", "bundled": true, - "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -4903,20 +4895,17 @@ "minimatch": { "version": "3.0.4", "bundled": true, - "optional": true, "requires": { "brace-expansion": "^1.1.7" } }, "minimist": { "version": "0.0.8", - "bundled": true, - "optional": true + "bundled": true }, "minipass": { "version": "2.3.5", "bundled": true, - "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -4933,7 +4922,6 @@ "mkdirp": { "version": "0.5.1", "bundled": true, - "optional": true, "requires": { "minimist": "0.0.8" } @@ -5006,8 +4994,7 @@ }, "number-is-nan": { "version": "1.0.1", - "bundled": true, - "optional": true + "bundled": true }, "object-assign": { "version": "4.1.1", @@ -5017,7 +5004,6 @@ "once": { "version": "1.4.0", "bundled": true, - "optional": true, "requires": { "wrappy": "1" } @@ -5093,8 +5079,7 @@ }, "safe-buffer": { "version": "5.1.2", - "bundled": true, - "optional": true + "bundled": true }, "safer-buffer": { "version": "2.1.2", @@ -5124,7 +5109,6 @@ "string-width": { "version": "1.0.2", "bundled": true, - "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -5142,7 +5126,6 @@ "strip-ansi": { "version": "3.0.1", "bundled": true, - "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -5181,13 +5164,11 @@ }, "wrappy": { "version": "1.0.2", - "bundled": true, - "optional": true + "bundled": true }, "yallist": { "version": "3.0.3", - "bundled": true, - "optional": true + "bundled": true } } }, diff --git a/src/plugins/login/azureLoginPlugin.test.ts b/src/plugins/login/azureLoginPlugin.test.ts index 9e715952..51e43b3f 100644 --- a/src/plugins/login/azureLoginPlugin.test.ts +++ b/src/plugins/login/azureLoginPlugin.test.ts @@ -68,7 +68,7 @@ describe("Login Plugin", () => { undefined // would be options ) expect(AzureLoginService.interactiveLogin).not.toBeCalled(); - expect(sls.variables["azureCredentials"]).toEqual(credentials); + expect(JSON.stringify(sls.variables["azureCredentials"])).toEqual(JSON.stringify(credentials)); expect(sls.variables["subscriptionId"]).toEqual("azureSubId"); }); @@ -78,7 +78,7 @@ describe("Login Plugin", () => { await invokeLoginHook(false, sls); expect(AzureLoginService.servicePrincipalLogin).not.toBeCalled(); expect(AzureLoginService.interactiveLogin).toBeCalled(); - expect(sls.variables["azureCredentials"]).toEqual(credentials); + expect(JSON.stringify(sls.variables["azureCredentials"])).toEqual(JSON.stringify(credentials)); expect(sls.variables["subscriptionId"]).toEqual("azureSubId"); }); diff --git a/src/plugins/login/utils/simpleFileTokenCache.test.ts b/src/plugins/login/utils/simpleFileTokenCache.test.ts new file mode 100644 index 00000000..09919d92 --- /dev/null +++ b/src/plugins/login/utils/simpleFileTokenCache.test.ts @@ -0,0 +1,84 @@ +import fs from "fs"; +import mockFs from "mock-fs"; +import { MockFactory } from "../../../test/mockFactory"; +import { SimpleFileTokenCache } from "./simpleFileTokenCache"; + +describe("Simple File Token Cache", () => { + const tokenFilePath = "slsTokenCache.json"; + + let fileContent = { + entries: [], + subscriptions: [], + }; + + afterEach(() => { + mockFs.restore(); + }); + + it("Creates a load file on creation if none", () => { + mockFs(); + + const writeFileSpy = jest.spyOn(fs, "writeFileSync"); + new SimpleFileTokenCache(tokenFilePath); + + const expected = { + entries: [], + subscriptions: [], + }; + + expect(writeFileSpy).toBeCalledWith(tokenFilePath, JSON.stringify(expected)); + writeFileSpy.mockRestore(); + }); + + it("Load file on creation if available", () => { + fileContent.entries = MockFactory.createTestTokenCacheEntries(); + + mockFs({ + "slsTokenCache.json": JSON.stringify(fileContent), + }); + + const readFileSpy = jest.spyOn(fs, "readFileSync"); + const tokenCache = new SimpleFileTokenCache(tokenFilePath); + + expect(readFileSpy).toBeCalled(); + expect(tokenCache.first()).not.toBeNull(); + readFileSpy.mockRestore(); + }) + + it("Saves to file after token is added", () => { + mockFs(); + + const writeFileSpy = jest.spyOn(fs, "writeFileSync"); + const tokenCache = new SimpleFileTokenCache(tokenFilePath); + const testEntries = MockFactory.createTestTokenCacheEntries(); + + tokenCache.add(testEntries); + + const expected = { + entries: testEntries, + subscriptions: [], + }; + + expect(tokenCache.isEmpty()).toBe(false); + expect(writeFileSpy).toBeCalledWith(tokenFilePath, JSON.stringify(expected)); + writeFileSpy.mockRestore(); + }); + + it("Saves to file after subscription is added", () => { + mockFs(); + + const writeFileSpy = jest.spyOn(fs, "writeFileSync"); + const testFileCache = new SimpleFileTokenCache(tokenFilePath); + const testSubs = MockFactory.createTestSubscriptions(); + + testFileCache.addSubs(testSubs); + + const expected = { + entries: [], + subscriptions: testSubs, + }; + + expect(writeFileSpy).toBeCalledWith(tokenFilePath, JSON.stringify(expected)); + writeFileSpy.mockRestore(); + }); +}); diff --git a/src/plugins/login/utils/simpleFileTokenCache.ts b/src/plugins/login/utils/simpleFileTokenCache.ts new file mode 100644 index 00000000..7f734cf1 --- /dev/null +++ b/src/plugins/login/utils/simpleFileTokenCache.ts @@ -0,0 +1,92 @@ +import fs from "fs"; +import path from "path"; +import os from "os"; +import * as adal from "adal-node"; + +const CONFIG_DIRECTORY = path.join(os.homedir(), ".azure"); +const DEFAULT_SLS_TOKEN_FILE = path.join(CONFIG_DIRECTORY, "slsTokenCache.json"); + +export class SimpleFileTokenCache implements adal.TokenCache { + private entries: any[] = []; + private subscriptions: any[] = []; + + public constructor(private tokenPath: string = DEFAULT_SLS_TOKEN_FILE) { + this.load(); + } + + public add(entries: any[], callback?: (err?: Error, result?: boolean) => void) { + this.entries.push(...entries); + this.save(); + if (callback) { + callback(); + } + } + + public remove(entries: any[], callback?: (err?: Error, result?: null) => void) { + this.entries = this.entries.filter(e => { + return !Object.keys(entries[0]).every(key => e[key] === entries[0][key]); + }); + this.save(); + if (callback) { + callback(); + } + } + + public find(query: any, callback: (err?: Error, result?: any[]) => void) { + let result = this.entries.filter(e => { + return Object.keys(query).every(key => e[key] === query[key]); + }); + callback(null, result); + return result; + } + + //-------- File toke cache specific methods + + public addSubs(subscriptions: any) { + this.subscriptions.push(...subscriptions); + this.subscriptions = this.subscriptions.reduce((accumulator , current) => { + const x = accumulator .find(item => item.id === current.id); + if (!x) { + return accumulator .concat([current]); + } else { + return accumulator ; + } + }, []); + this.save(); + } + + + public clear(callback: any) { + this.entries = []; + this.subscriptions = []; + this.save(); + callback(); + } + + public isEmpty() { + return this.entries.length === 0; + } + + public first() { + return this.entries[0]; + } + + public listSubscriptions() { + return this.subscriptions; + } + + private load() { + if (fs.existsSync(this.tokenPath)) { + let savedCache = JSON.parse(fs.readFileSync(this.tokenPath).toString()); + this.entries = savedCache.entries; + this.entries.map(t => t.expiresOn = new Date(t.expiresOn)) + this.subscriptions = savedCache.subscriptions; + } else { + this.save(); + } + } + + public save() { + fs.writeFileSync(this.tokenPath, JSON.stringify({ entries: this.entries, subscriptions: this.subscriptions })); + } +} diff --git a/src/services/baseService.ts b/src/services/baseService.ts index cbf5acbe..d516e694 100644 --- a/src/services/baseService.ts +++ b/src/services/baseService.ts @@ -129,15 +129,16 @@ export abstract class BaseService { /** * Get the access token from credentials token cache */ - protected getAccessToken(): string { - return (this.credentials.tokenCache as any)._entries[0].accessToken; + protected async getAccessToken(): Promise { + const token = await this.credentials.getToken(); + return token ? token.accessToken : null; } /** * Sends an API request using axios HTTP library * @param method The HTTP method * @param relativeUrl The relative url - * @param options Additional HTTP options including headers, etc + * @param options Additional HTTP options including headers, etc. */ protected async sendApiRequest( method: string, @@ -145,7 +146,7 @@ export abstract class BaseService { options: any = {} ) { const defaultHeaders = { - Authorization: `Bearer ${this.getAccessToken()}` + Authorization: `Bearer ${await this.getAccessToken()}` }; const allHeaders = { diff --git a/src/services/functionAppService.test.ts b/src/services/functionAppService.test.ts index 9de1cf7a..715819a7 100644 --- a/src/services/functionAppService.test.ts +++ b/src/services/functionAppService.test.ts @@ -211,7 +211,7 @@ describe("Function App Service", () => { uri: expectedUploadUrl, json: true, headers: { - Authorization: `Bearer ${variables["azureCredentials"].tokenCache._entries[0].accessToken}`, + Authorization: `Bearer ${(await variables["azureCredentials"].getToken()).accessToken}`, Accept: "*/*", ContentType: "application/octet-stream", } @@ -242,7 +242,7 @@ describe("Function App Service", () => { uri: expectedUploadUrl, json: true, headers: { - Authorization: `Bearer ${variables["azureCredentials"].tokenCache._entries[0].accessToken}`, + Authorization: `Bearer ${(await variables["azureCredentials"].getToken()).accessToken}`, Accept: "*/*", ContentType: "application/octet-stream", } @@ -276,7 +276,7 @@ describe("Function App Service", () => { uri: expectedUploadUrl, json: true, headers: { - Authorization: `Bearer ${variables["azureCredentials"].tokenCache._entries[0].accessToken}`, + Authorization: `Bearer ${(await variables["azureCredentials"].getToken()).accessToken}`, Accept: "*/*", ContentType: "application/octet-stream", } diff --git a/src/services/functionAppService.ts b/src/services/functionAppService.ts index a94f575b..61357fa7 100644 --- a/src/services/functionAppService.ts +++ b/src/services/functionAppService.ts @@ -205,7 +205,7 @@ export class FunctionAppService extends BaseService { uri: `https://${scmDomain}/api/zipdeploy/`, json: true, headers: { - Authorization: `Bearer ${this.getAccessToken()}`, + Authorization: `Bearer ${await this.getAccessToken()}`, Accept: "*/*", ContentType: "application/octet-stream", } diff --git a/src/services/loginService.test.ts b/src/services/loginService.test.ts index a43e5114..941b4fa3 100644 --- a/src/services/loginService.test.ts +++ b/src/services/loginService.test.ts @@ -2,21 +2,50 @@ import { AzureLoginService } from "./loginService"; jest.mock("open"); import open from "open"; -jest.mock("@azure/ms-rest-nodeauth") -import { interactiveLoginWithAuthResponse, loginWithServicePrincipalSecretWithAuthResponse } from "@azure/ms-rest-nodeauth"; +jest.mock("@azure/ms-rest-nodeauth"); +import * as nodeauth from "@azure/ms-rest-nodeauth"; + +jest.mock("../plugins/login/utils/simpleFileTokenCache"); +import { SimpleFileTokenCache } from "../plugins/login/utils/simpleFileTokenCache"; describe("Login Service", () => { - - it("logs in interactively", async () => { + + it("logs in interactively with no cached login", async () => { // Ensure env variables are not set delete process.env.azureSubId; delete process.env.azureServicePrincipalClientId; delete process.env.azureServicePrincipalPassword; delete process.env.azureServicePrincipalTenantId; + SimpleFileTokenCache.prototype.isEmpty = jest.fn(() => true); + + const emptyObj = { subscriptions: [] }; + Object.defineProperty(nodeauth, + "interactiveLoginWithAuthResponse", + { value: jest.fn(_obj => emptyObj) } + ); + + await AzureLoginService.login(); + expect(SimpleFileTokenCache).toBeCalled(); + expect(open).toBeCalledWith("https://microsoft.com/devicelogin"); + expect(nodeauth.interactiveLoginWithAuthResponse).toBeCalled(); + expect(SimpleFileTokenCache.prototype.addSubs).toBeCalledWith(emptyObj.subscriptions); + }); + + it("logs in with a cached login", async () => { + // Ensure env variables are not set + delete process.env.azureSubId; + delete process.env.azureServicePrincipalClientId; + delete process.env.azureServicePrincipalPassword; + delete process.env.azureServicePrincipalTenantId; + + SimpleFileTokenCache.prototype.isEmpty = jest.fn(() => false); + SimpleFileTokenCache.prototype.first = jest.fn(() => ({ userId: "" })); + await AzureLoginService.login(); - expect(open).toBeCalledWith("https://microsoft.com/devicelogin") - expect(interactiveLoginWithAuthResponse).toBeCalled(); + expect(SimpleFileTokenCache).toBeCalled(); + expect(nodeauth.DeviceTokenCredentials).toBeCalled(); + expect(SimpleFileTokenCache.prototype.listSubscriptions).toBeCalled(); }); it("logs in with a service principal", async () => { @@ -27,11 +56,11 @@ describe("Login Service", () => { process.env.azureServicePrincipalTenantId = "azureServicePrincipalTenantId"; await AzureLoginService.login(); - expect(loginWithServicePrincipalSecretWithAuthResponse).toBeCalledWith( + expect(nodeauth.loginWithServicePrincipalSecretWithAuthResponse).toBeCalledWith( "azureServicePrincipalClientId", "azureServicePrincipalPassword", "azureServicePrincipalTenantId", undefined // would be options ); }); -}); \ No newline at end of file +}); diff --git a/src/services/loginService.ts b/src/services/loginService.ts index 94d00b9f..6bf433f2 100644 --- a/src/services/loginService.ts +++ b/src/services/loginService.ts @@ -5,15 +5,16 @@ import { loginWithServicePrincipalSecretWithAuthResponse, AuthResponse, AzureTokenCredentialsOptions, - InteractiveLoginOptions, + InteractiveLoginOptions, + DeviceTokenCredentials, } from "@azure/ms-rest-nodeauth"; +import { SimpleFileTokenCache } from "../plugins/login/utils/simpleFileTokenCache"; export interface AzureLoginOptions extends Serverless.Options { subscriptionId?: string; } export class AzureLoginService { - /** * Logs in via service principal login if environment variables are * set or via interactive login if environment variables are not set @@ -32,9 +33,20 @@ export class AzureLoginService { } } - public static async interactiveLogin(options: InteractiveLoginOptions): Promise { - await open("https://microsoft.com/devicelogin"); - return await interactiveLoginWithAuthResponse(options); + public static async interactiveLogin(options?: InteractiveLoginOptions): Promise { + let authResp: AuthResponse = {credentials: undefined, subscriptions: []}; + const fileTokenCache = new SimpleFileTokenCache(); + if(fileTokenCache.isEmpty()){ + await open("https://microsoft.com/devicelogin"); + authResp = await interactiveLoginWithAuthResponse({...options, tokenCache: fileTokenCache}); + fileTokenCache.addSubs(authResp.subscriptions); + } else { + authResp.credentials = new DeviceTokenCredentials(undefined, undefined, fileTokenCache.first().userId, undefined, undefined, fileTokenCache); + authResp.subscriptions = fileTokenCache.listSubscriptions(); + } + + return authResp; + } public static async servicePrincipalLogin(clientId: string, secret: string, tenantId: string, options: AzureTokenCredentialsOptions): Promise { diff --git a/src/test/mockFactory.ts b/src/test/mockFactory.ts index 5eb34a30..b44a7e67 100644 --- a/src/test/mockFactory.ts +++ b/src/test/mockFactory.ts @@ -126,22 +126,39 @@ export class MockFactory { public static createTestAzureCredentials(): TokenClientCredentials { const credentials = { getToken: jest.fn(() => { - const token: TokenResponse = { - tokenType: "Bearer", - accessToken: "ABC123", - }; + const token: TokenResponse = this.createTestTokenCacheEntries()[0]; return Promise.resolve(token); }), signRequest: jest.fn((resource) => Promise.resolve(resource)), }; - // TODO: Reduce usage on tokenCache._entries[0] - credentials["tokenCache"] = { - _entries: [{ accessToken: "ABC123" }] + return credentials; + } + + public static createTestTokenCacheEntries(count: number = 1): TokenResponse[] { + const token: TokenResponse = { + tokenType: "Bearer", + accessToken: "ABC123", + userId: "example@user.com", }; + const result = Array(count).fill(token); - return credentials; + return result; + } + + public static createTestSubscriptions(count: number = 1): any[] { + const sub = { + id: "abc-1234-5678", + state: "Enabled", + authorizationSource: "RoleBased", + user: { name: "example@user.com", type: "user" }, + environmentName: "AzureCloud", + name: "Test Sub" + }; + const result = Array(count).fill(sub); + + return result; } public static createTestTimestamp(): string { @@ -351,15 +368,7 @@ export class MockFactory { public static createTestVariables() { return { - azureCredentials: { - tokenCache: { - _entries: [ - { - accessToken: "token" - } - ] - } - }, + azureCredentials: this.createTestAzureCredentials(), subscriptionId: "azureSubId", } }