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

[core-client] authorizeRequestOnClaimChallenge #17315

Merged
16 commits merged into from
Oct 29, 2021
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 2 additions & 0 deletions sdk/core/core-rest-pipeline/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

### Features Added

- Added a new policy to parse [Continuous Access Evaluation](https://docs.microsoft.com/azure/active-directory/conditional-access/concept-continuous-access-evaluation) challenges from the ARM SDK clients: `armChallengeAuthenticationPolicy`.

### Breaking Changes

### Bugs Fixed
Expand Down
10 changes: 10 additions & 0 deletions sdk/core/core-rest-pipeline/review/core-rest-pipeline.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@ export interface Agent {
sockets: unknown;
}

// @public
export function armChallengeAuthenticationPolicy(options: ARMChallengeAuthenticationPolicyOptions): PipelinePolicy;

// @public
export const ARMChallengeAuthenticationPolicyName = "armChallengeAuthenticationPolicy";

// @public
export interface ARMChallengeAuthenticationPolicyOptions extends BearerTokenAuthenticationPolicyOptions {
}

// @public
export interface AuthorizeRequestOnChallengeOptions {
getAccessToken: (scopes: string[], options: GetTokenOptions) => Promise<AccessToken | null>;
Expand Down
5 changes: 5 additions & 0 deletions sdk/core/core-rest-pipeline/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,9 @@ export {
AuthorizeRequestOptions,
AuthorizeRequestOnChallengeOptions
} from "./policies/bearerTokenAuthenticationPolicy";
export {
armChallengeAuthenticationPolicy,
ARMChallengeAuthenticationPolicyOptions,
ARMChallengeAuthenticationPolicyName
} from "./policies/armChallengeAuthenticationPolicy";
export { ndJsonPolicy, ndJsonPolicyName } from "./policies/ndJsonPolicy";
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { GetTokenOptions } from "../../../core-auth/types/latest/core-auth";
import { PipelinePolicy } from "../pipeline";
import {
AuthorizeRequestOnChallengeOptions,
bearerTokenAuthenticationPolicy,
BearerTokenAuthenticationPolicyOptions,
defaultAuthorizeRequest
} from "./bearerTokenAuthenticationPolicy";

/**
* The programmatic identifier of the bearerTokenAuthenticationPolicy.
*/
export const ARMChallengeAuthenticationPolicyName = "armChallengeAuthenticationPolicy";

/**
* Options to configure the armChallengeAuthenticationPolicy
*/
export interface ARMChallengeAuthenticationPolicyOptions
extends BearerTokenAuthenticationPolicyOptions {}

/**
* Converts a uint8Array to a string.
* @internal
*/
export function uint8ArrayToString(ab: Uint8Array): string {
const decoder = new TextDecoder("utf-8");
sadasant marked this conversation as resolved.
Show resolved Hide resolved
return decoder.decode(ab);
}

/**
* Encodes a string in base64 format.
* @param value - The string to encode
* @internal
*/
export function encodeString(value: string): string {
return Buffer.from(value).toString("base64");
sadasant marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* Decodes a base64 string into a byte array.
* @param value - The base64 string to decode
* @internal
*/
export function decodeString(value: string): Uint8Array {
return Buffer.from(value, "base64");
}

/**
* Converts: `Bearer a="b", c="d", Bearer d="e", f="g"`.
* Into: `[ { a: 'b', c: 'd' }, { d: 'e', f: 'g"' } ]`.
*
* Important:
* Do not use this in production, as values might contain the strings we use to split things up.
*
* @internal
*/
function parseCAEChallenge(challenges: string): any[] {
return challenges
.split("Bearer ")
.filter((x) => x)
.map((challenge) =>
`${challenge.trim()}, `
.split('", ')
.filter((x) => x)
.map((keyValue) => (([key, value]) => ({ [key]: value }))(keyValue.trim().split('="')))
.reduce((a, b) => ({ ...a, ...b }), {})
);
}

/**
* CAE Challenge structure
*/
export interface CAEChallenge {
scope: string;
claims: string;
}

/**
* A policy that extends the `bearerTokenAuthenticationPolicy` to support CAE challenges:
* [Continuous Access Evaluation](https://docs.microsoft.com/azure/active-directory/conditional-access/concept-continuous-access-evaluation).
*/
export function armChallengeAuthenticationPolicy(
sadasant marked this conversation as resolved.
Show resolved Hide resolved
options: ARMChallengeAuthenticationPolicyOptions
): PipelinePolicy {
const { credential, scopes, challengeCallbacks } = options;
sadasant marked this conversation as resolved.
Show resolved Hide resolved
const callbacks = {
authorizeRequest: challengeCallbacks?.authorizeRequest ?? defaultAuthorizeRequest,
authorizeRequestOnChallenge: challengeCallbacks?.authorizeRequestOnChallenge,
// keep all other properties
...challengeCallbacks
};

if (!callbacks.authorizeRequestOnChallenge) {
let cachedChallenge: string | undefined;

callbacks.authorizeRequestOnChallenge = async (
onChallengeOptions: AuthorizeRequestOnChallengeOptions
): Promise<boolean> => {
const { scopes: onChallengeScopes } = onChallengeOptions;

const challenge = onChallengeOptions.response.headers.get("WWW-Authenticate");
if (!challenge) {
throw new Error("Missing challenge");
}
const challenges: CAEChallenge[] = parseCAEChallenge(challenge) || [];

const parsedChallenge = challenges.find((x) => x.claims);
if (!parsedChallenge) {
throw new Error("Missing claims");
}
if (cachedChallenge !== challenge) {
cachedChallenge = challenge;
}

const accessToken = await onChallengeOptions.getAccessToken(
parsedChallenge.scope ? [parsedChallenge.scope] : onChallengeScopes,
{
...onChallengeOptions,
claims: uint8ArrayToString(Buffer.from(parsedChallenge.claims, "base64"))
} as GetTokenOptions
);

if (!accessToken) {
return false;
}

onChallengeOptions.request.headers.set("Authorization", `Bearer ${accessToken.token}`);
return true;
};
}

return {
...bearerTokenAuthenticationPolicy({ credential, scopes, challengeCallbacks: callbacks }),
name: ARMChallengeAuthenticationPolicyName
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,9 @@ export interface BearerTokenAuthenticationPolicyOptions {

/**
* Default authorize request handler
* @internal
*/
async function defaultAuthorizeRequest(options: AuthorizeRequestOptions): Promise<void> {
export async function defaultAuthorizeRequest(options: AuthorizeRequestOptions): Promise<void> {
sadasant marked this conversation as resolved.
Show resolved Hide resolved
const { scopes, getAccessToken, request } = options;
const getTokenOptions: GetTokenOptions = {
abortSignal: request.abortSignal,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,100 +5,14 @@ import { assert } from "chai";
import * as sinon from "sinon";
import { AccessToken, GetTokenOptions, TokenCredential } from "@azure/core-auth";
import {
bearerTokenAuthenticationPolicy,
AuthorizeRequestOnChallengeOptions,
armChallengeAuthenticationPolicy,
createEmptyPipeline,
createHttpHeaders,
createPipelineRequest,
HttpClient,
PipelineResponse
} from "../../src";
import { TextDecoder } from "util";

export interface TestChallenge {
scope: string;
claims: string;
}

let cachedChallenge: string | undefined;

/**
* Converts a uint8Array to a string.
*/
export function uint8ArrayToString(ab: Uint8Array): string {
const decoder = new TextDecoder("utf-8");
return decoder.decode(ab);
}

/**
* Encodes a string in base64 format.
* @param value - The string to encode
*/
export function encodeString(value: string): string {
return Buffer.from(value).toString("base64");
}

/**
* Decodes a base64 string into a byte array.
* @param value - The base64 string to decode
*/
export function decodeString(value: string): Uint8Array {
return Buffer.from(value, "base64");
}

// Converts:
// Bearer a="b", c="d", Bearer d="e", f="g"
// Into:
// [ { a: 'b', c: 'd' }, { d: 'e', f: 'g"' } ]
// Important:
// Do not use this in production, as values might contain the strings we use to split things up.
function parseCAEChallenge(challenges: string): any[] {
return challenges
.split("Bearer ")
.filter((x) => x)
.map((challenge) =>
`${challenge.trim()}, `
.split('", ')
.filter((x) => x)
.map((keyValue) => (([key, value]) => ({ [key]: value }))(keyValue.trim().split('="')))
.reduce((a, b) => ({ ...a, ...b }), {})
);
}

async function authorizeRequestOnChallenge(
options: AuthorizeRequestOnChallengeOptions
): Promise<boolean> {
const { scopes } = options;

const challenge = options.response.headers.get("WWW-Authenticate");
if (!challenge) {
throw new Error("Missing challenge");
}
const challenges: TestChallenge[] = parseCAEChallenge(challenge) || [];

const parsedChallenge = challenges.find((x) => x.claims);
if (!parsedChallenge) {
throw new Error("Missing claims");
}
if (cachedChallenge !== challenge) {
cachedChallenge = challenge;
}

const accessToken = await options.getAccessToken(
parsedChallenge.scope ? [parsedChallenge.scope] : scopes,
{
...options,
claims: uint8ArrayToString(Buffer.from(parsedChallenge.claims, "base64"))
} as GetTokenOptions
);

if (!accessToken) {
return false;
}

options.request.headers.set("Authorization", `Bearer ${accessToken.token}`);
return true;
}
import { encodeString } from "../../src/policies/armChallengeAuthenticationPolicy";

class MockRefreshAzureCredential implements TokenCredential {
public authCount = 0;
Expand All @@ -119,7 +33,7 @@ class MockRefreshAzureCredential implements TokenCredential {
}
}

describe("bearerTokenAuthenticationPolicy with challenge", function() {
describe("armChallengeAuthenticationPolicy with challenge", function() {
let clock: sinon.SinonFakeTimers;

beforeEach(() => {
Expand Down Expand Up @@ -161,7 +75,7 @@ describe("bearerTokenAuthenticationPolicy with challenge", function() {

const pipeline = createEmptyPipeline();
let firstRequest: boolean = true;
const bearerPolicy = bearerTokenAuthenticationPolicy({
const bearerPolicy = armChallengeAuthenticationPolicy({
// Intentionally left empty, as it should be replaced by the challenge.
scopes: [],
credential,
Expand All @@ -174,8 +88,7 @@ describe("bearerTokenAuthenticationPolicy with challenge", function() {
const token = await getAccessToken([], {});
request.headers.set("Authorization", `Bearer ${token}`);
}
},
authorizeRequestOnChallenge
}
}
});
pipeline.addPolicy(bearerPolicy);
Expand Down Expand Up @@ -270,7 +183,7 @@ describe("bearerTokenAuthenticationPolicy with challenge", function() {
const pipeline = createEmptyPipeline();
let firstRequest: boolean = true;
let previousToken: AccessToken | null;
const bearerPolicy = bearerTokenAuthenticationPolicy({
const bearerPolicy = armChallengeAuthenticationPolicy({
// Intentionally left empty, as it should be replaced by the challenge.
scopes: [],
credential,
Expand All @@ -288,8 +201,7 @@ describe("bearerTokenAuthenticationPolicy with challenge", function() {
}
request.headers.set("Authorization", `Bearer ${previousToken.token}`);
}
},
authorizeRequestOnChallenge
}
}
});
pipeline.addPolicy(bearerPolicy);
Expand Down Expand Up @@ -344,7 +256,7 @@ describe("bearerTokenAuthenticationPolicy with challenge", function() {

const pipeline = createEmptyPipeline();
let firstRequest: boolean = true;
const bearerPolicy = bearerTokenAuthenticationPolicy({
const bearerPolicy = armChallengeAuthenticationPolicy({
// Intentionally left empty, as it should be replaced by the challenge.
scopes: [],
credential,
Expand All @@ -357,8 +269,7 @@ describe("bearerTokenAuthenticationPolicy with challenge", function() {
const token = await getAccessToken([], {});
request.headers.set("Authorization", `Bearer ${token}`);
}
},
authorizeRequestOnChallenge
}
}
});
pipeline.addPolicy(bearerPolicy);
Expand Down