Skip to content
Merged
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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- macOS, Windows and CLI: **a role behind a Conditional Access authentication context could not be
activated at all.** The activation was refused, the browser step-up was completed, and the retry
was refused again with the same message — "the sign-in did not satisfy it" — however many times
it was repeated. Two faults, both on the path between the step-up and the retry:

Elevate never declared the `cp1` client capability, so its tokens carried no `xms_cc` claim. PIM
will not honour an authentication context (`acrs`) from a client that has not said it understands
a claims challenge: it answered every activation with `RoleAssignmentRequestAcrsValidationFailed`
and re-issued the same challenge, even for a token that plainly carried the context. Elevate has
always handled claims challenges, so it now says so — through MSAL's client configuration on
macOS, Windows and the CLI, and as an `xms_cc` claims request on every token the loopback
providers mint themselves, refreshes included, since a refreshed token would otherwise lose it.

And the token the step-up produced was thrown away: the retry asked MSAL silently for "the" token
for those scopes, and MSAL bypasses its access-token cache whenever a claims request is specified
and makes no promise to write the result back into it, so the retry could go out with the token
from *before* the step-up. The step-up token is now held for its own lifetime and is the one the
retry sends, which also spares the second and third role behind the same context their own
browser round trip.

- macOS: the panel's confirmations (Remove tenant, Sign out, Delete profile) did nothing and
dismissed the panel on click. A `confirmationDialog` opens its own window, which took key focus
from the menu bar panel and closed it before the click reached a button. They now confirm inline,
Expand Down
28 changes: 28 additions & 0 deletions cli/src/Elevate.Cli/Auth/MsalCliProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ public sealed class MsalCliProvider : ITokenProvider
private readonly Action<string> _say;
private readonly Lazy<Task> _registered;

/// <summary>
/// Tokens from a claims (step-up) acquisition. MSAL bypasses its own access-token cache when a
/// claims request is specified, so the silent call that follows the step-up can hand back the
/// token from before it; the retry would then be refused for the same missing claim.
/// </summary>
private readonly StepUpTokenCache _stepUp = new();

public MsalCliProvider(SignInMethod method, string clientId, TokenCacheStore cache, InteractiveGate gate, InteractiveFlow flow, Action<string> say)
{
ArgumentNullException.ThrowIfNull(cache);
Expand Down Expand Up @@ -77,6 +84,7 @@ public async Task<Identity> SignInAsync(SignInMethod method, CancellationToken c
public async Task SignOutAsync(Identity identity, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(identity);
_stepUp.Forget(identity.Id);
await _registered.Value.ConfigureAwait(false);
if (await FindAccountAsync(identity).ConfigureAwait(false) is { } account)
{
Expand Down Expand Up @@ -114,6 +122,13 @@ public Task<string> FreshAccessTokenAsync(Identity identity, string tenantId, IR
private async Task<string> SilentAsync(Identity identity, string tenantId, IReadOnlyList<string> scopes, bool forceRefresh, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(identity);

// A forced refresh wants a token minted now, so it skips the step-up token as it skips MSAL's cache.
if (!forceRefresh && _stepUp.Token(identity.Id, tenantId, Requested(scopes)) is { } stepped)
{
return stepped;
}

await _registered.Value.ConfigureAwait(false);
var account = await FindAccountAsync(identity).ConfigureAwait(false)
?? throw new PimException(PimErrorKind.InteractionRequired);
Expand All @@ -131,6 +146,13 @@ public async Task<string> AcquireInteractivelyAsync(Identity identity, string te
var account = await FindAccountAsync(identity).ConfigureAwait(false);
var result = await _gate.RunAsync(
() => InteractiveAsync(Requested(scopes), account, tenantId, claims, ct), ct).ConfigureAwait(false);

// Only a claims acquisition is worth holding: a plain one has nothing MSAL's cache lacks.
if (!string.IsNullOrEmpty(claims))
{
_stepUp.Store(result.AccessToken, identity.Id, tenantId, Requested(scopes));
}

return result.AccessToken;
}

Expand Down Expand Up @@ -227,6 +249,12 @@ private static IPublicClientApplication Build(string clientId)
return PublicClientApplicationBuilder.Create(clientId)
.WithAuthority(AzureCloudInstance.AzurePublic, "organizations")
.WithRedirectUri("http://localhost")
// "cp1" tells Entra, and the resource, that this client understands a claims
// challenge and will re-acquire against it. PIM refuses to honour an authentication
// context (`acrs`) from a client that has not said so: it answers the activation
// with RoleAssignmentRequestAcrsValidationFailed and re-issues the same challenge,
// however many times the token is re-minted. MSAL turns this into `xms_cc`.
.WithClientCapabilities(["cp1"])
.Build();
}
catch (MsalException e)
Expand Down
21 changes: 21 additions & 0 deletions macos/Sources/ElevateApp/MSAL/MSALTokenProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ final class MSALTokenProvider: TokenProviding, @unchecked Sendable {
private let anchor: AuthAnchorWindow
/// Shared with the loopback providers so an MSAL webview and a browser flow cannot run at once.
private let gate: InteractiveGate
/// Tokens from a claims (step-up) acquisition. MSAL bypasses its own access-token cache when a
/// claims request is specified, so the silent call that follows the step-up can hand back the
/// token from before it; the retry would then be refused for the same missing claim.
private let stepUp = StepUpTokenCache()
/// The method stamped on the identities this provider returns: `.ownApp` for the Settings
/// registration, `.pinnedApp` for a registration an account was added with.
let method: SignInMethod
Expand All @@ -21,6 +25,12 @@ final class MSALTokenProvider: TokenProviding, @unchecked Sendable {
precondition(method.isOwnApp, "MSAL serves only Entra app registrations, got \(method)")
let authority = try MSALAADAuthority(url: URL(string: "https://login.microsoftonline.com/organizations")!)
let config = MSALPublicClientApplicationConfig(clientId: clientId, redirectUri: redirectUri, authority: authority)
// "cp1" tells Entra, and the resource, that this client understands a claims challenge and
// will re-acquire against it. PIM refuses to honour an authentication context (`acrs`) from
// a client that has not said so: it answers the activation with
// RoleAssignmentRequestAcrsValidationFailed and re-issues the same challenge, however many
// times the token is re-minted. MSAL turns this into the token's `xms_cc` claim.
config.clientApplicationCapabilities = [ClaimsChallenge.clientCapability]
app = try MSALPublicClientApplication(configuration: config)
self.anchor = anchor
self.gate = gate
Expand All @@ -40,6 +50,7 @@ final class MSALTokenProvider: TokenProviding, @unchecked Sendable {
}

func signOut(_ identity: Identity) async throws {
await stepUp.forget(identityId: identity.id)
guard let account = try? app.account(forIdentifier: identity.id) else { return }
try await gate.run { [self] in
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
Expand All @@ -61,6 +72,7 @@ final class MSALTokenProvider: TokenProviding, @unchecked Sendable {
/// sign-out would only interrupt the user.
func removeCachedAccounts(_ identities: [Identity]) throws {
for identity in identities {
Task { await stepUp.forget(identityId: identity.id) }
guard let account = try? app.account(forIdentifier: identity.id) else { continue }
try app.remove(account)
}
Expand All @@ -78,6 +90,11 @@ final class MSALTokenProvider: TokenProviding, @unchecked Sendable {
var canForceRefresh: Bool { true }

func accessToken(identity: Identity, tenantId: String, scopes: [String], forceRefresh: Bool) async throws -> String {
// A forced refresh wants a token minted now, so it skips the step-up token as it skips MSAL's cache.
if !forceRefresh,
let stepped = await stepUp.token(identityId: identity.id, tenantId: tenantId, scopes: scopes) {
return stepped
}
guard let account = try? app.account(forIdentifier: identity.id) else { throw PIMError.interactionRequired }
let params = MSALSilentTokenParameters(scopes: scopes, account: account)
params.authority = try MSALAADAuthority(url: URL(string: "https://login.microsoftonline.com/\(tenantId)")!)
Expand All @@ -94,6 +111,10 @@ final class MSALTokenProvider: TokenProviding, @unchecked Sendable {
let account = try? app.account(forIdentifier: identity.id)
return try await gate.run { [self] in
let result = try await interactive(account: account, tenantId: tenantId, scopes: scopes, claims: claims, prompt: .promptIfNecessary)
// Only a claims acquisition is worth holding: a plain one has nothing MSAL's cache lacks.
if claims != nil {
await stepUp.store(result.accessToken, identityId: identity.id, tenantId: tenantId, scopes: scopes)
}
return result.accessToken
}
}
Expand Down
6 changes: 6 additions & 0 deletions macos/Sources/ElevateCore/Auth/AccessTokenClaims.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ public enum AccessTokenClaims {
return Set(scp.split(separator: " ").map(String.init))
}

/// When the token expires (`exp`), or nil when the token is opaque or carries no expiry.
public static func expiry(_ accessToken: String) -> Date? {
guard let exp = payload(accessToken)?["exp"] as? NSNumber else { return nil }
return Date(timeIntervalSince1970: exp.doubleValue)
}

/// The caller's object id in the token's tenant (`oid`), or nil when the token is opaque.
public static func objectId(_ accessToken: String) -> String? {
payload(accessToken)?["oid"] as? String
Expand Down
5 changes: 5 additions & 0 deletions macos/Sources/ElevateCore/Auth/AuthorizationCodeClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,11 @@ public struct AuthorizationCodeClient: Sendable {

private func post(tenant: String, form: [String: String]) async throws -> HTTPResponse {
let url = URL(string: "https://login.microsoftonline.com/\(tenant)/oauth2/v2.0/token")!
// Every token this client mints declares the client capability, the way MSAL declares it
// from its configuration: a token without `xms_cc` is one PIM will not accept an
// authentication context from, and a refreshed token would otherwise quietly lose it.
var form = form
form["claims"] = ClaimsChallenge.clientCapabilities
let body = form.sorted { $0.key < $1.key }.map { "\($0.key)=\(Self.formEncode($0.value))" }.joined(separator: "&")
return try await http.send(HTTPRequest(method: "POST", url: url, headers: ["Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"], body: Data(body.utf8)))
}
Expand Down
54 changes: 54 additions & 0 deletions macos/Sources/ElevateCore/Auth/StepUpTokenCache.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import Foundation

/// Holds the access token a step-up sign-in just produced, so the call that follows uses *that*
/// token rather than whatever the SDK's cache still holds.
///
/// MSAL bypasses its access-token cache whenever a claims request is specified, and does not
/// promise to write the token it hands back into that cache. Asking it silently for a token right
/// after a step-up can therefore return the pre-step-up one — which carries no `acrs` claim, so the
/// service refuses the retry for exactly the reason it refused the first attempt, and the user is
/// told their verification did not count. `OAuthSession` already caches the loopback providers'
/// tokens itself; this is the same guarantee for MSAL.
public actor StepUpTokenCache {
struct Key: Hashable { let identityId: String; let tenantId: String; let scopes: String }
struct Entry { let token: String; let expiresAt: Date }

/// Discarded this long before the token's own expiry, so a token that expires mid-call is not handed out.
static let skew: TimeInterval = 60
/// Lifetime assumed for a token whose expiry cannot be read (an opaque, non-JWT token). Long
/// enough to cover the retry the step-up was made for, short enough to be no one's cache.
static let opaqueLifetime: TimeInterval = 300

private var entries: [Key: Entry] = [:]
private let now: @Sendable () -> Date

public init(now: @escaping @Sendable () -> Date = { Date() }) { self.now = now }

/// Remembers a token acquired with a claims request. Call only for a claims acquisition: a
/// plain interactive token has nothing the SDK's own cache lacks.
public func store(_ token: String, identityId: String, tenantId: String, scopes: [String]) {
let expiry = AccessTokenClaims.expiry(token) ?? now().addingTimeInterval(Self.opaqueLifetime)
entries[Self.key(identityId, tenantId, scopes)] = Entry(token: token, expiresAt: expiry)
}

/// The stored token for this identity, tenant and scope set, or nil once it is gone or too near expiry.
public func token(identityId: String, tenantId: String, scopes: [String]) -> String? {
let key = Self.key(identityId, tenantId, scopes)
guard let entry = entries[key] else { return nil }
guard entry.expiresAt.timeIntervalSince(now()) > Self.skew else {
entries[key] = nil
return nil
}
return entry.token
}

/// Drops everything held for one identity: it signed out, or its client id changed.
public func forget(identityId: String) {
entries = entries.filter { $0.key.identityId != identityId }
}

/// Scope order is the caller's accident, not part of the identity of a token.
private static func key(_ identityId: String, _ tenantId: String, _ scopes: [String]) -> Key {
Key(identityId: identityId, tenantId: tenantId, scopes: scopes.sorted().joined(separator: " "))
}
}
18 changes: 17 additions & 1 deletion macos/Sources/ElevateCore/Networking/ClaimsChallenge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,25 @@ public enum ClaimsChallenge {
public static let multiFactor = #"{"access_token":{"amr":{"values":["mfa"]}}}"#

/// Claims request for a Conditional Access authentication context (`acrs`), for roles whose
/// policy carries `AuthenticationContext_EndUser_Assignment`.
/// policy carries `AuthenticationContext_EndUser_Assignment`. It asks for the context alone:
/// MSAL merges the client capability in from its own configuration, and a second `xms_cc` here
/// would collide with it. The loopback providers, which have no MSAL to do that, send
/// `clientCapabilities` on their own acquisitions instead.
public static func authenticationContext(_ id: String) -> String {
let escaped = id.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"")
return #"{"access_token":{"acrs":{"essential":true,"value":""# + escaped + #""}}}"#
}

/// The capability that says this client understands a claims challenge and will re-acquire
/// against it. Without it Entra omits the `xms_cc` claim, and PIM refuses to honour an
/// authentication context the token plainly carries: it answers the activation with
/// `RoleAssignmentRequestAcrsValidationFailed` and re-issues the same challenge for as long as
/// the client keeps re-minting tokens. MSAL declares it from the client configuration; the
/// loopback providers, which speak to the token endpoint themselves, send it as a claims
/// request on every acquisition.
public static let clientCapability = "cp1"

/// The claims request that declares nothing but the client capability, for a token acquisition
/// that carries no challenge of its own.
public static let clientCapabilities = #"{"access_token":{"xms_cc":{"values":[""# + clientCapability + #""]}}}"#
}
9 changes: 9 additions & 0 deletions macos/Tests/ElevateCoreTests/AccessTokenClaimsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ import Foundation
}
}

@Test func readsExpiryFromExpClaim() {
let body = try! JSONSerialization.data(withJSONObject: ["exp": 1_700_000_000])
let b64 = body.base64EncodedString().replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_").trimmingCharacters(in: CharacterSet(charactersIn: "="))
#expect(AccessTokenClaims.expiry("eyJhbGciOiJub25lIn0.\(b64).sig") == Date(timeIntervalSince1970: 1_700_000_000))
#expect(AccessTokenClaims.expiry("not-a-jwt") == nil)
#expect(AccessTokenClaims.expiry(token(scp: nil)) == nil)
}

@Test func opaqueOrScopelessTokenIsUnknown() {
#expect(AccessTokenClaims.permitsEntraActivation("not-a-jwt") == nil)
#expect(AccessTokenClaims.permitsEntraActivation(token(scp: nil)) == nil)
Expand Down
11 changes: 11 additions & 0 deletions macos/Tests/ElevateCoreTests/AuthorizationCodeClientTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,15 @@ import Foundation
#expect(AuthorizationCodeClient.resourceScope(for: ["https://graph.microsoft.com/User.Read", "https://graph.microsoft.com/RoleEligibilitySchedule.Read.Directory"]) == "https://graph.microsoft.com/.default")
#expect(AuthorizationCodeClient.resourceScope(for: ["https://management.azure.com/user_impersonation"]) == "https://management.azure.com/.default")
}

@Test func everyTokenRequestDeclaresTheClientCapability() async throws {
// Without `xms_cc` in the token, PIM refuses to honour an authentication context, so the
// capability has to ride on the refresh too, not only on the sign-in.
let http = StubHTTPClient()
await http.on("POST", "/tenant-2/oauth2/v2.0/token", body: Data(#"{"token_type":"Bearer","expires_in":100,"access_token":"AT2"}"#.utf8))
_ = try await AuthorizationCodeClient(http: http).refresh(refreshToken: "RT", clientId: clientId, tenant: "tenant-2", scopes: ["https://management.azure.com/user_impersonation"])
let form = String(decoding: (await http.requests.first!).body!, as: UTF8.self)
#expect(form.contains("claims=\(AuthorizationCodeClient.formEncode(ClaimsChallenge.clientCapabilities))"))
#expect(ClaimsChallenge.clientCapabilities.contains(#""xms_cc""#) && ClaimsChallenge.clientCapabilities.contains("cp1"))
}
}
Loading
Loading