Skip to content

Commit 079a52c

Browse files
authored
[ContainerRegistry] add AAD support and remove Basic Auth support (Azure#14595)
- Add a private copy of CAE support for container registry - Add AAD support - Remove basic auth support
1 parent aad8e1c commit 079a52c

File tree

9 files changed

+770
-125
lines changed

9 files changed

+770
-125
lines changed

sdk/containerregistry/container-registry/review/container-registry.api.md

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { TokenCredential } from '@azure/core-auth';
1111

1212
// @public
1313
export class ContainerRegistryClient {
14-
constructor(endpointUrl: string, credential: TokenCredential | ContainerRegistryUserCredential, options?: ContainerRegistryClientOptions);
14+
constructor(endpointUrl: string, credential: TokenCredential, options?: ContainerRegistryClientOptions);
1515
deleteRepository(name: string, options?: DeleteRepositoryOptions): Promise<DeleteRepositoryResult>;
1616
endpoint: string;
1717
listRepositories(options?: ListRepositoriesOptions): PagedAsyncIterableIterator<string, string[]>;
@@ -21,17 +21,9 @@ export class ContainerRegistryClient {
2121
export interface ContainerRegistryClientOptions extends PipelineOptions {
2222
}
2323

24-
// @public
25-
export class ContainerRegistryUserCredential {
26-
constructor(username: string, pass: string);
27-
get pass(): string;
28-
update(pass: string): void;
29-
get username(): string;
30-
}
31-
3224
// @public
3325
export class ContainerRepositoryClient {
34-
constructor(endpointUrl: string, repository: string, credential: TokenCredential | ContainerRegistryUserCredential, options?: ContainerRegistryClientOptions);
26+
constructor(endpointUrl: string, repository: string, credential: TokenCredential, options?: ContainerRegistryClientOptions);
3527
delete(options?: DeleteOptions): Promise<DeleteRepositoryResult>;
3628
deleteRegistryArtifact(digest: string, options?: DeleteRegistryArtifactOptions): Promise<void>;
3729
deleteTag(tag: string, options?: DeleteTagOptions): Promise<void>;

sdk/containerregistry/container-registry/samples/javascript/containerRegistryClient.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ async function deleteRepository(client) {
4242
console.log(`Tags deleted: ${response.deletedRegistryArtifactDigests.length || 0}`);
4343
}
4444

45-
main().catch((err) => {
46-
console.error("The sample encountered an error:", err);
47-
});
45+
main()
46+
.then(() => {
47+
console.log("Sample completes successfully.");
48+
})
49+
.catch((err) => {
50+
console.error("The sample encountered an error:", err);
51+
});
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
import { TokenCredential, GetTokenOptions, AccessToken } from "@azure/core-auth";
5+
import {
6+
PipelineResponse,
7+
PipelineRequest,
8+
SendRequest,
9+
PipelinePolicy
10+
} from "@azure/core-rest-pipeline";
11+
import { createTokenCycler } from "./tokenCycler";
12+
13+
/**
14+
* The programmatic identifier of the bearerTokenAuthenticationPolicy.
15+
*/
16+
export const bearerTokenChallengeAuthenticationPolicyName =
17+
"bearerTokenChallengeAuthenticationPolicy";
18+
19+
/**
20+
* Options sent to the challenge callbacks
21+
*/
22+
export interface ChallengeCallbackOptions {
23+
/**
24+
* The scopes for which the bearer token applies.
25+
*/
26+
scopes: string | string[];
27+
/**
28+
* Additional claims to be included in the token.
29+
* For more information on format and content: [the claims parameter specification](href="https://openid.net/specs/openid-connect-core-1_0-final.html#ClaimsParameter).
30+
*/
31+
claims?: string;
32+
/**
33+
* Copy of the last token used, if any.
34+
*/
35+
previousToken?: AccessToken;
36+
/**
37+
* Function that retrieves either a cached token or a new token.
38+
*/
39+
getToken: (scopes: string | string[], options: GetTokenOptions) => Promise<AccessToken | null>;
40+
/**
41+
* Request that the policy is trying to fulfill.
42+
*/
43+
request: PipelineRequest;
44+
/**
45+
* Function that allows easily assigning a token to the request.
46+
*/
47+
setAuthorizationHeader: (token: string) => void;
48+
}
49+
50+
/**
51+
* Options to override the processing of [Continuous Access Evaluation](https://docs.microsoft.com/azure/active-directory/conditional-access/concept-continuous-access-evaluation) challenges.
52+
*/
53+
export interface ChallengeCallbacks {
54+
/**
55+
* Allows for the authentication of the main request of this policy before it's sent.
56+
* The `setAuthorizationHeader` parameter received through the `ChallengeCallbackOptions`
57+
* allows developers to easily assign a token to the ongoing request.
58+
*/
59+
authenticateRequest?(options: ChallengeCallbackOptions): Promise<void>;
60+
/**
61+
* Allows to handle authentication challenges and to re-authenticate the request.
62+
* The `setAuthorizationHeader` parameter received through the `ChallengeCallbackOptions`
63+
* allows developers to easily assign a token to the ongoing request.
64+
* If this method returns true, the underlying request will be sent once again.
65+
*/
66+
authenticateRequestOnChallenge(
67+
challenge: string,
68+
options: ChallengeCallbackOptions
69+
): Promise<boolean>;
70+
}
71+
72+
/**
73+
* Options to configure the bearerTokenAuthenticationPolicy
74+
*/
75+
export interface BearerTokenAuthenticationPolicyOptions {
76+
/**
77+
* The TokenCredential implementation that can supply the bearer token.
78+
*/
79+
credential: TokenCredential;
80+
/**
81+
* The scopes for which the bearer token applies.
82+
*/
83+
scopes: string | string[];
84+
/**
85+
* Allows for the processing of [Continuous Access Evaluation](https://docs.microsoft.com/azure/active-directory/conditional-access/concept-continuous-access-evaluation) challenges.
86+
* If provided, it must contain at least the `authenticateRequestOnChallenge` method.
87+
* If provided, after a request is sent, if it has a challenge, it can be processed to re-send the original request with the relevant challenge information.
88+
*/
89+
challengeCallbacks?: ChallengeCallbacks;
90+
}
91+
92+
/**
93+
* Retrieves a token from a token cache or a credential.
94+
*/
95+
export async function retrieveToken(
96+
options: ChallengeCallbackOptions
97+
): Promise<AccessToken | undefined> {
98+
const { scopes, claims, getToken, request } = options;
99+
100+
const getTokenOptions: GetTokenOptions & { claims?: string } = {
101+
claims,
102+
abortSignal: request.abortSignal,
103+
tracingOptions: request.tracingOptions
104+
};
105+
106+
return (await getToken(scopes, getTokenOptions)) || undefined;
107+
}
108+
109+
/**
110+
* Default authenticate request
111+
*/
112+
export async function defaultAuthenticateRequest(options: ChallengeCallbackOptions): Promise<void> {
113+
const accessToken = await retrieveToken(options);
114+
if (!accessToken) {
115+
return;
116+
}
117+
options.setAuthorizationHeader(accessToken.token);
118+
}
119+
120+
/**
121+
* Default authenticate request on challenge
122+
*/
123+
export async function defaultAuthenticateRequestOnChallenge(
124+
challenge: string,
125+
options: ChallengeCallbackOptions
126+
): Promise<boolean> {
127+
const { scopes, setAuthorizationHeader } = options;
128+
129+
if (!challenge) {
130+
return false;
131+
}
132+
const { scope, claims } = parseWWWAuthenticate(challenge);
133+
134+
const accessToken = await retrieveToken({
135+
...options,
136+
scopes: scope || scopes,
137+
claims
138+
});
139+
140+
if (!accessToken) {
141+
return false;
142+
}
143+
144+
setAuthorizationHeader(accessToken.token);
145+
return true;
146+
}
147+
148+
/**
149+
* A policy that can request a token from a TokenCredential implementation and
150+
* then apply it to the Authorization header of a request as a Bearer token.
151+
*/
152+
export function bearerTokenChallengeAuthenticationPolicy(
153+
options: BearerTokenAuthenticationPolicyOptions
154+
): PipelinePolicy {
155+
const { credential, scopes, challengeCallbacks } = options;
156+
const callbacks = {
157+
authenticateRequest: challengeCallbacks?.authenticateRequest ?? defaultAuthenticateRequest,
158+
authenticateRequestOnChallenge:
159+
challengeCallbacks?.authenticateRequestOnChallenge ?? defaultAuthenticateRequestOnChallenge,
160+
// If any of the properties is set to undefined, it will replace the default values.
161+
...challengeCallbacks
162+
};
163+
164+
/**
165+
* We will retrieve the challenge only if the response status code was 401,
166+
* and if the response contained the header "WWW-Authenticate" with a non-empty value.
167+
*/
168+
function getChallenge(response: PipelineResponse): string | undefined {
169+
const challenge = response.headers.get("WWW-Authenticate");
170+
if (response.status === 401 && challenge) {
171+
return challenge;
172+
}
173+
return;
174+
}
175+
176+
// This function encapsulates the entire process of reliably retrieving the token
177+
// The options are left out of the public API until there's demand to configure this.
178+
// Remember to extend `BearerTokenAuthenticationPolicyOptions` with `TokenCyclerOptions`
179+
// in order to pass through the `options` object.
180+
const cycler = createTokenCycler(credential /* , options */);
181+
182+
return {
183+
name: bearerTokenChallengeAuthenticationPolicyName,
184+
/**
185+
* If there's no challenge parameter:
186+
* - It will try to retrieve the token using the cache, or the credential's getToken.
187+
* - Then it will try the next policy with or without the retrieved token.
188+
*
189+
* It uses the challenge parameters to:
190+
* - Skip a first attempt to get the token from the credential if there's no cached token,
191+
* since it expects the token to be retrievable only after the challenge.
192+
* - Prepare the outgoing request if the `prepareRequest` method has been provided.
193+
* - Send an initial request to receive the challenge if it fails.
194+
* - Process a challenge if the response contains it.
195+
* - Retrieve a token with the challenge information, then re-send the request.
196+
*/
197+
async sendRequest(request: PipelineRequest, next: SendRequest): Promise<PipelineResponse> {
198+
// Allows users to easily set the authorization header.
199+
function setAuthorizationHeader(token: string): void {
200+
request.headers.set("Authorization", `Bearer ${token}`);
201+
}
202+
203+
if (callbacks?.authenticateRequest) {
204+
await callbacks.authenticateRequest({
205+
scopes,
206+
request,
207+
previousToken: cycler.cachedToken,
208+
getToken: cycler.getToken,
209+
setAuthorizationHeader
210+
});
211+
}
212+
213+
let response: PipelineResponse;
214+
let error: Error | undefined;
215+
try {
216+
response = await next(request);
217+
} catch (err) {
218+
error = err;
219+
response = err.response;
220+
}
221+
const challenge = getChallenge(response);
222+
223+
if (challenge && callbacks?.authenticateRequestOnChallenge) {
224+
// processes challenge
225+
const shouldSendRequest = await callbacks.authenticateRequestOnChallenge(challenge, {
226+
scopes,
227+
request,
228+
previousToken: cycler.cachedToken,
229+
getToken: cycler.getToken,
230+
setAuthorizationHeader
231+
});
232+
233+
if (shouldSendRequest) {
234+
return next(request);
235+
}
236+
}
237+
238+
if (error) {
239+
throw error;
240+
} else {
241+
return response;
242+
}
243+
}
244+
};
245+
}
246+
247+
type ValidParsedWWWAuthenticateProperties =
248+
// "authorization_uri" was used in the track 1 version of KeyVault.
249+
// This is not a relevant property anymore, since the service is consistently answering with "authorization".
250+
// | "authorization_uri"
251+
| "authorization"
252+
| "claims"
253+
// Even though the service is moving to "scope", both "resource" and "scope" should be supported.
254+
| "resource"
255+
| "scope"
256+
| "service";
257+
258+
type ParsedWWWAuthenticate = {
259+
[Key in ValidParsedWWWAuthenticateProperties]?: string;
260+
};
261+
262+
/**
263+
* Parses an WWW-Authenticate response.
264+
* This transforms a string value like:
265+
* `Bearer authorization="some_authorization", resource="https://some.url"`
266+
* into an object like:
267+
* `{ authorization: "some_authorization", resource: "https://some.url" }`
268+
* @param wwwAuthenticate - String value in the WWW-Authenticate header
269+
*/
270+
export function parseWWWAuthenticate(wwwAuthenticate: string): ParsedWWWAuthenticate {
271+
// First we split the string by either `,`, `, ` or ` `.
272+
const parts = wwwAuthenticate.split(/, *| +/);
273+
// Then we only keep the strings with an equal sign after a word and before a quote.
274+
// also splitting these sections by their equal sign
275+
const keyValues = parts.reduce<string[][]>(
276+
(acc, str) => (str.match(/\w="/) ? [...acc, str.split("=")] : acc),
277+
[]
278+
);
279+
// Then we transform these key-value pairs back into an object.
280+
const parsed = keyValues.reduce<ParsedWWWAuthenticate>(
281+
(result, [key, value]: string[]) => ({
282+
...result,
283+
[key]: value.slice(1, -1)
284+
}),
285+
{}
286+
);
287+
return parsed;
288+
}

0 commit comments

Comments
 (0)