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

WIP: Implements accessToken as new authType. Closes #5816 #6268

Draft
wants to merge 2 commits into
base: main
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
69 changes: 51 additions & 18 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,19 +200,17 @@ export class Auth {
}

public async ensureAccessToken(resource: string, logger: Logger, debug: boolean = false, fetchNew: boolean = false): Promise<string> {
const now: Date = new Date();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if the specified access token expired? I suggest that we still perform this logic as early as possible to catch errors as soon as possible

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't remove the logic, it's still there. also for the new authType

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);

if (!fetchNew && accessToken && expiresOn > now) {
if (!fetchNew && accessToken && !this.accessTokenExpired(accessToken)) {
if (debug) {
await logger.logToStderr(`Existing access token ${accessToken.accessToken} still valid. Returning...`);
}
return accessToken.accessToken;

// If the authType is accessToken, we handle returning the token later on.
if (this.connection.authType !== AuthType.AccessToken) {
return accessToken.accessToken;
}
}
else {
if (debug) {
Expand All @@ -229,10 +228,12 @@ export class Auth {
// 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
// wasn't specified. For accessToken auth, we can't fetch a new token,
// as the tokens are passed in through the login command.
if (this.connection.authType !== AuthType.Certificate &&
this.connection.authType !== AuthType.Secret &&
this.connection.authType !== AuthType.Identity) {
this.connection.authType !== AuthType.Identity &&
this.connection.authType !== AuthType.AccessToken) {
this.clientApplication = await this.getPublicClient(logger, debug);
if (this.clientApplication) {
const accounts = await this.clientApplication.getTokenCache().getAllAccounts();
Expand Down Expand Up @@ -263,6 +264,9 @@ export class Auth {
case AuthType.Secret:
getTokenPromise = this.ensureAccessTokenWithSecret.bind(this);
break;
case AuthType.AccessToken:
getTokenPromise = this.returnValidAccessTokenForResource.bind(this);
break;
}
}

Expand Down Expand Up @@ -304,6 +308,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 +435,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 @@ -507,7 +522,7 @@ export class Auth {
const pemObjs = pem.decode(cert);

if (this.connection.thumbprint === undefined) {
const pemCertObj = pemObjs.find(pem => pem.type === "CERTIFICATE");
const pemCertObj = pemObjs.find(pem => pem.type === 'CERTIFICATE');
const pemCertStr: string = pem.encode(pemCertObj!);
const pemCert = pki.certificateFromPem(pemCertStr);

Expand Down Expand Up @@ -657,11 +672,11 @@ export class Auth {
let isNotFoundResponse = false;
if (e.error && e.error.Message) {
// check if it is Azure Function api 'not found' response
isNotFoundResponse = (e.error.Message.indexOf("No Managed Identity found") !== -1);
isNotFoundResponse = (e.error.Message.indexOf('No Managed Identity found') !== -1);
}
else if (e.error && e.error.error_description) {
// check if it is Azure VM api 'not found' response
isNotFoundResponse = (e.error.error_description === "Identity not found");
isNotFoundResponse = (e.error.error_description === 'Identity not found');
}

if (!isNotFoundResponse) {
Expand Down Expand Up @@ -706,6 +721,24 @@ export class Auth {
});
}

private async returnValidAccessTokenForResource(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)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should handle this centrally for all tokens rather than for each auth type separately. The logic is the same and having it done early allows us to speed up auth flow and avoid repetition

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, yes, but in the case of this new authType we want slightly different behavior: I want to throw if it's expired for example. Do you have suggestions on how to do it differently?

throw `Access token for resource '${resource}' expired. 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 +911,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
7 changes: 4 additions & 3 deletions src/Command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -513,11 +513,12 @@ export default abstract class Command {
}

private loadValuesFromAccessToken(args: CommandArgs): void {
if (!auth.connection.accessTokens[auth.defaultResource]) {
const resource = Object.keys(auth.connection.accessTokens)[0];
if (!auth.connection.accessTokens[resource]) {
return;
}

const token = auth.connection.accessTokens[auth.defaultResource].accessToken;
const token = auth.connection.accessTokens[resource].accessToken;
const optionNames: string[] = Object.getOwnPropertyNames(args.options);
optionNames.forEach(option => {
const value = args.options[option];
Expand All @@ -527,7 +528,7 @@ export default abstract class Command {

const lowerCaseValue = value.toLowerCase().trim();
if (lowerCaseValue === '@meid' || lowerCaseValue === '@meusername') {
const isAppOnlyAccessToken = accessToken.isAppOnlyAccessToken(auth.connection.accessTokens[auth.defaultResource].accessToken);
const isAppOnlyAccessToken = accessToken.isAppOnlyAccessToken(auth.connection.accessTokens[resource].accessToken);
if (isAppOnlyAccessToken) {
throw `It's not possible to use ${value} with application permissions`;
}
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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems duplicate of the line above

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I needed to circumvent it defaulting to the pnp management shell Id... I only need the value if it's in an environment variable.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it. Since we'll be no longer using the hardcoded value, do we still need this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If your PR comes through first, no we don't...

tenant: process.env.CLIMICROSOFT365_TENANT || 'common',
configstoreName: 'cli-m365-config'
};
126 changes: 123 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 { accessToken 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.validatesAccessTokensAreForSingleTenant(options)), {
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.validatesAccessTokensAreForSingleApp(options)), {
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.validatesAccessTokensAreForSingleResource(options)), {
message: 'Specify access tokens that are not for the same resource'
})
.refine(options => !(options.authType === 'accessToken' && options.accessToken && !this.validatesAccessTokensNotExpired(options)), {
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.getTenantIdFromAccessToken(accessTokens[0]);
auth.connection.tenant = accessTokenUtil.getAppIdFromAccessToken(accessTokens[0]);

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

auth.connection.accessTokens[resource] = {
expiresOn: expiresOn as Date || null,
waldekmastykarz marked this conversation as resolved.
Show resolved Hide resolved
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,80 @@ class LoginCommand extends Command {
await this.initAction(args, logger);
await this.commandAction(logger, args);
}

private validatesAccessTokensAreForSingleTenant(options: Options): boolean {
const accessTokens = typeof options.accessToken === 'string' ? [options.accessToken] : options.accessToken as string[];
let tenant = options.tenant || config.tenant;

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

if (tenant !== 'common' && tenant !== tenantIdInAccessToken) {
return false;
}

tenant = tenantIdInAccessToken;
};

return true;
}

private validatesAccessTokensAreForSingleApp(options: Options): boolean {
const accessTokens = typeof options.accessToken === 'string' ? [options.accessToken] : options.accessToken as string[];
let appId = options.appId || config.cliEnvEntraAppId || '';

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

if (appId !== '' && appId !== appIdInAccessToken) {
return false;
}

appId = appIdInAccessToken;
};

return true;
}

private validatesAccessTokensAreForSingleResource(options: Options): boolean {
const accessTokens = typeof options.accessToken === 'string' ? [options.accessToken] : options.accessToken as string[];
const resources: string[] = [];

if (accessTokens.length === 1) {
return true;
}

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

if (resources.indexOf(resource) > -1) {
return false;
}

resources.push(resource);
};

return true;
}

private validatesAccessTokensNotExpired(options: Options): boolean {
const accessTokens = typeof options.accessToken === 'string' ? [options.accessToken] : options.accessToken as string[];

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

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

if (auth.accessTokenExpired(accessToken)) {
return false;
}
};

return true;
}
}

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
2 changes: 1 addition & 1 deletion src/m365/entra/commands/app/app-add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ class EntraAppAddCommand extends GraphCommand {
// directory. If we in the future extend the command with allowing
// users to create Microsoft Entra app in a different directory, we'll need to
// adjust this
appInfo.tenantId = accessToken.getTenantIdFromAccessToken(auth.connection.accessTokens[auth.defaultResource].accessToken);
appInfo.tenantId = accessToken.getTenantIdFromAccessToken(auth.connection.accessTokens[Object.keys(auth.connection.accessTokens)[0]].accessToken);
appInfo = await this.updateAppFromManifest(args, appInfo);
appInfo = await this.grantAdminConsent(appInfo, args.options.grantAdminConsent, logger);
appInfo = await this.configureUri(args, appInfo, logger);
Expand Down
Loading
Loading