Skip to content

Commit

Permalink
Implements accessToken as new authType. Closes #5816
Browse files Browse the repository at this point in the history
  • Loading branch information
martinlingstuyl committed Aug 22, 2024
1 parent a0e1bec commit 96b4404
Show file tree
Hide file tree
Showing 5 changed files with 279 additions and 45 deletions.
110 changes: 70 additions & 40 deletions src/Auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,8 @@ export enum AuthType {
Certificate,
Identity,
Browser,
Secret
Secret,
AccessToken
}

export enum CertificateType {
Expand Down Expand Up @@ -199,46 +200,43 @@ export class Auth {
}

public async ensureAccessToken(resource: string, logger: Logger, debug: boolean = false, fetchNew: boolean = false): Promise<string> {
const now: Date = new Date();
const accessToken: AccessToken | undefined = this.connection.accessTokens[resource];
const expiresOn: Date = accessToken && accessToken.expiresOn ?
// if expiresOn is serialized from the service file, it's set as a string
// if it's coming from MSAL, it's a Date
typeof accessToken.expiresOn === 'string' ? new Date(accessToken.expiresOn) : accessToken.expiresOn
: new Date(0);
let getTokenPromise: ((resource: string, logger: Logger, debug: boolean, fetchNew: boolean) => Promise<AccessToken | null>) | undefined;

if (!fetchNew && accessToken && expiresOn > now) {
if (debug) {
await logger.logToStderr(`Existing access token ${accessToken.accessToken} still valid. Returning...`);
}
return accessToken.accessToken;
}
else {
if (debug) {
if (!accessToken) {
await logger.logToStderr(`No token found for resource ${resource}.`);
// If the authType is accessToken, we handle returning the token later on.
if (this.connection.authType !== AuthType.AccessToken) {
const accessToken: AccessToken | undefined = this.connection.accessTokens[resource];

if (!fetchNew && accessToken && !this.accessTokenExpired(accessToken)) {
if (debug) {
await logger.logToStderr(`Existing access token ${accessToken.accessToken} still valid. Returning...`);
}
else {
await logger.logToStderr(`Access token expired. Token: ${accessToken.accessToken}, ExpiresAt: ${accessToken.expiresOn}`);
return accessToken.accessToken;
}
else {
if (debug) {
if (!accessToken) {
await logger.logToStderr(`No token found for resource ${resource}.`);
}
else {
await logger.logToStderr(`Access token expired. Token: ${accessToken.accessToken}, ExpiresAt: ${accessToken.expiresOn}`);
}
}
}
}

let getTokenPromise: ((resource: string, logger: Logger, debug: boolean, fetchNew: boolean) => Promise<AccessToken | null>) | undefined;

// When using an application identity, you can't retrieve the access token silently, because there is
// no account. Also (for cert auth) clientApplication is instantiated later
// after inspecting the specified cert and calculating thumbprint if one
// wasn't specified
if (this.connection.authType !== AuthType.Certificate &&
this.connection.authType !== AuthType.Secret &&
this.connection.authType !== AuthType.Identity) {
this.clientApplication = await this.getPublicClient(logger, debug);
if (this.clientApplication) {
const accounts = await this.clientApplication.getTokenCache().getAllAccounts();
// if there is an account in the cache and it's active, we can try to get the token silently
if (accounts.filter(a => a.localAccountId === this.connection.identityId).length > 0 && this.connection.active === true) {
getTokenPromise = this.ensureAccessTokenSilent.bind(this);
// When using an application identity, you can't retrieve the access token silently, because there is
// no account. Also (for cert auth) clientApplication is instantiated later
// after inspecting the specified cert and calculating thumbprint if one
// wasn't specified
if (this.connection.authType !== AuthType.Certificate &&
this.connection.authType !== AuthType.Secret &&
this.connection.authType !== AuthType.Identity) {
this.clientApplication = await this.getPublicClient(logger, debug);
if (this.clientApplication) {
const accounts = await this.clientApplication.getTokenCache().getAllAccounts();
// if there is an account in the cache and it's active, we can try to get the token silently
if (accounts.filter(a => a.localAccountId === this.connection.identityId).length > 0 && this.connection.active === true) {
getTokenPromise = this.ensureAccessTokenSilent.bind(this);
}
}
}
}
Expand All @@ -263,6 +261,9 @@ export class Auth {
case AuthType.Secret:
getTokenPromise = this.ensureAccessTokenWithSecret.bind(this);
break;
case AuthType.AccessToken:
getTokenPromise = this.ensureAccessTokenWithAccessToken.bind(this);
break;
}
}

Expand Down Expand Up @@ -304,6 +305,17 @@ export class Auth {
return response.accessToken;
}

public accessTokenExpired(accessToken: AccessToken): boolean {
const now: Date = new Date();
const expiresOn: Date = accessToken && accessToken.expiresOn ?
// if expiresOn is serialized from the service file, it's set as a string
// if it's coming from MSAL, it's a Date
typeof accessToken.expiresOn === 'string' ? new Date(accessToken.expiresOn) : accessToken.expiresOn
: new Date(0);

return expiresOn <= now;
}

private async getAuthClientConfiguration(logger: Logger, debug: boolean, certificateThumbprint?: string, certificatePrivateKey?: string, clientSecret?: string): Promise<Msal.Configuration> {
const msal: typeof Msal = await import('@azure/msal-node');
const { LogLevel } = msal;
Expand Down Expand Up @@ -420,13 +432,13 @@ export class Auth {
}

// Asserting identityId because it is expected to be available at this point.
assert(this.connection.identityId !== undefined);
assert(this.connection.identityId !== undefined, "identityId is undefined");

const account = await (this.clientApplication as Msal.ClientApplication)
.getTokenCache().getAccountByLocalId(this.connection.identityId);

// Asserting account because it is expected to be available at this point.
assert(account !== null);
assert(account !== null, "account is null");

return (this.clientApplication as Msal.ClientApplication).acquireTokenSilent({
account: account,
Expand Down Expand Up @@ -706,6 +718,24 @@ export class Auth {
});
}

private async ensureAccessTokenWithAccessToken(resource: string, logger: Logger, debug: boolean): Promise<AccessToken | null> {
const accessToken: AccessToken | undefined = this.connection.accessTokens[resource];

if (!accessToken) {
throw `No token found for resource ${resource}.`;
}

if (this.accessTokenExpired(accessToken)) {
throw `Access token expired. Token: ${accessToken.accessToken}, ExpiresAt: ${accessToken.expiresOn}`;
}

if (debug) {
await logger.logToStderr(`Existing access token ${accessToken.accessToken} still valid. Returning...`);
}

return accessToken;
}

private async calculateThumbprint(certificate: NodeForge.pki.Certificate): Promise<string> {
const nodeForge = (await import('node-forge')).default;
const { md, asn1, pki } = nodeForge;
Expand Down Expand Up @@ -878,8 +908,8 @@ export class Auth {

public getConnectionDetails(connection: Connection): ConnectionDetails {
// Asserting name and identityId because they are optional, but required at this point.
assert(connection.identityName !== undefined);
assert(connection.name !== undefined);
assert(connection.identityName !== undefined, "identity name is undefined");
assert(connection.name !== undefined, "connection name is undefined");

const details: ConnectionDetails = {
connectionName: connection.name,
Expand Down
1 change: 1 addition & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export default {
applicationName: `CLI for Microsoft 365 v${app.packageJson().version}`,
delimiter: 'm365\$',
cliEntraAppId: process.env.CLIMICROSOFT365_ENTRAAPPID || process.env.CLIMICROSOFT365_AADAPPID || cliEntraAppId,
cliEnvEntraAppId: process.env.CLIMICROSOFT365_ENTRAAPPID || process.env.CLIMICROSOFT365_AADAPPID,
tenant: process.env.CLIMICROSOFT365_TENANT || 'common',
configstoreName: 'cli-m365-config'
};
134 changes: 131 additions & 3 deletions src/m365/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ import config from '../../config.js';
import { settingsNames } from '../../settingsNames.js';
import { zod } from '../../utils/zod.js';
import commands from './commands.js';
import * as accessTokenUtil from '../../utils/accessToken.js';

const options = globalOptionsZod
.extend({
authType: zod.alias('t', z.enum(['certificate', 'deviceCode', 'password', 'identity', 'browser', 'secret']).optional()),
authType: zod.alias('t', z.enum(['certificate', 'deviceCode', 'password', 'identity', 'browser', 'secret', 'accessToken']).optional()),
cloud: z.nativeEnum(CloudType).optional().default(CloudType.Public),
userName: zod.alias('u', z.string().optional()),
password: zod.alias('p', z.string().optional()),
Expand All @@ -26,6 +27,7 @@ const options = globalOptionsZod
appId: z.string().optional(),
tenant: z.string().optional(),
secret: zod.alias('s', z.string().optional()),
accessToken: zod.alias('a', z.string().or(z.array(z.string())).optional()),
connectionName: z.string().optional()
})
.strict();
Expand Down Expand Up @@ -64,6 +66,21 @@ class LoginCommand extends Command {
})
.refine(options => options.authType !== 'secret' || options.secret, {
message: 'Secret is required when using secret authentication'
})
.refine(options => options.authType !== 'accessToken' || options.accessToken, {
message: 'accessToken is required when using accessToken authentication'
})
.refine(options => !(options.authType === 'accessToken' && options.accessToken && this.tokensForMultipleTenants(options.accessToken, options.tenant)), {
message: 'The provided accessToken is not for the specified tenant or the access tokens are not for the same tenant'
})
.refine(options => !(options.authType === 'accessToken' && options.accessToken && this.tokensForMultipleApps(options.accessToken, options.appId)), {
message: 'The provided access token is not for the specified app or the access tokens are not for the same app'
})
.refine(options => !(options.authType === 'accessToken' && options.accessToken && this.tokensForTheSameResources(options.accessToken)), {
message: 'Specify access tokens that are not for the same resource'
})
.refine(options => !(options.authType === 'accessToken' && options.accessToken && this.tokenExpired(options.accessToken)), {
message: 'The provided access token has expired'
});
}

Expand Down Expand Up @@ -107,13 +124,37 @@ class LoginCommand extends Command {
case 'secret':
auth.connection.authType = AuthType.Secret;
auth.connection.secret = args.options.secret;
break;
case 'accessToken':
const accessTokens = typeof args.options.accessToken === "string" ? [args.options.accessToken] : args.options.accessToken as string[];
auth.connection.authType = AuthType.AccessToken;
auth.connection.appId = accessTokenUtil.accessToken.getTenantIdFromAccessToken(accessTokens[0]);
auth.connection.tenant = accessTokenUtil.accessToken.getAppIdFromAccessToken(accessTokens[0]);

for (const token of accessTokens) {
const resource = accessTokenUtil.accessToken.getAudienceFromAccessToken(token);
const expiresOn = accessTokenUtil.accessToken.getExpirationFromAccessToken(token);

auth.connection.accessTokens[resource] = {
expiresOn: expiresOn as Date || null,
accessToken: token
};
};

break;
}

auth.connection.cloudType = args.options.cloud;

try {
await auth.ensureAccessToken(auth.defaultResource, logger, this.debug);
if (auth.connection.authType !== AuthType.AccessToken) {
await auth.ensureAccessToken(auth.defaultResource, logger, this.debug);
}
else {
for (const resource of Object.keys(auth.connection.accessTokens)) {
await auth.ensureAccessToken(resource, logger, this.debug);
}
}
auth.connection.active = true;
}
catch (error: any) {
Expand All @@ -123,7 +164,12 @@ class LoginCommand extends Command {
await logger.logToStderr('');
}

throw new CommandError(error.message);
if (error instanceof Error) {
throw new CommandError(error.message);
}
else {
throw new CommandError(error);
}
}

const details = auth.getConnectionDetails(auth.connection);
Expand Down Expand Up @@ -151,6 +197,88 @@ class LoginCommand extends Command {
await this.initAction(args, logger);
await this.commandAction(logger, args);
}

private tokensForMultipleTenants(accessTokenValue: string | string[] | undefined, tenantValue: string | undefined): boolean {
const accessTokens = typeof accessTokenValue === "string" ? [accessTokenValue] : accessTokenValue as string[];;
let tenant = tenantValue || config.tenant;
let forMultipleTenants: boolean = false;

for (const token of accessTokens) {
const tenantIdInAccessToken = accessTokenUtil.accessToken.getTenantIdFromAccessToken(token);

if (tenant !== 'common' && tenant !== tenantIdInAccessToken) {
forMultipleTenants = true;
break;
}

tenant = tenantIdInAccessToken;
};

return forMultipleTenants;
}

private tokensForMultipleApps(accessTokenValue: string | string[] | undefined, appIdValue: string | undefined): boolean {
const accessTokens = typeof accessTokenValue === "string" ? [accessTokenValue] : accessTokenValue as string[];;
let appId = appIdValue || config.cliEnvEntraAppId || '';
let forMultipleApps: boolean = false;

for (const token of accessTokens) {
const appIdInAccessToken = accessTokenUtil.accessToken.getAppIdFromAccessToken(token);

if (appId !== '' && appId !== appIdInAccessToken) {
forMultipleApps = true;
break;
}

appId = appIdInAccessToken;
};

return forMultipleApps;
}

private tokensForTheSameResources(accessTokenValue: string | string[] | undefined): boolean {
const accessTokens = typeof accessTokenValue === "string" ? [accessTokenValue] : accessTokenValue as string[];;
let forTheSameResources: boolean = false;
const resources: string[] = [];

if ((accessTokens as string[]).length === 1) {
return false;
}

for (const token of accessTokens) {
const resource = accessTokenUtil.accessToken.getAudienceFromAccessToken(token);

if (resources.indexOf(resource) > -1) {
forTheSameResources = true;
break;
}

resources.push(resource);
};

return forTheSameResources;
}

private tokenExpired(accessTokenValue: string | string[] | undefined): boolean {
const accessTokens = typeof accessTokenValue === "string" ? [accessTokenValue] : accessTokenValue as string[];;
let tokenExpired: boolean = false;

for (const token of accessTokens) {
const expiresOn = accessTokenUtil.accessToken.getExpirationFromAccessToken(token);

const accessToken = {
expiresOn: expiresOn as Date || null,
accessToken: token
};

if (auth.accessTokenExpired(accessToken)) {
tokenExpired = true;
break;
}
};

return tokenExpired;
}
}

export default new LoginCommand();
11 changes: 9 additions & 2 deletions src/m365/commands/status.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import auth from '../../Auth.js';
import auth, { AuthType } from '../../Auth.js';
import { Logger } from '../../cli/Logger.js';
import Command, { CommandArgs, CommandError } from '../../Command.js';
import commands from './commands.js';
Expand All @@ -15,7 +15,14 @@ class StatusCommand extends Command {
public async commandAction(logger: Logger): Promise<void> {
if (auth.connection.active) {
try {
await auth.ensureAccessToken(auth.defaultResource, logger, this.debug);
if (auth.connection.authType !== AuthType.AccessToken) {
await auth.ensureAccessToken(auth.defaultResource, logger, this.debug);
}
else {
for (const resource of Object.keys(auth.connection.accessTokens)) {
await auth.ensureAccessToken(resource, logger, this.debug);
}
}
}
catch (err: any) {
if (this.debug) {
Expand Down
Loading

0 comments on commit 96b4404

Please sign in to comment.