diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index 6f63115..3e44c85 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -103,7 +103,43 @@ Soprano uses OAuth client-assertion exchange. The outbound user-assigned managed `api://AzureADTokenExchange/.default` assertion for the existing multitenant application, which then requests the configured provider scope. Existing platform caller authentication is unchanged. -Use a speech language supported by the selected Soprano endpoint and account. On QA4, an OAuth +### Soprano provider JWT + +The Function obtains one provider token through the shared OAuth credential resolver using the +setup-generated `EPP_PROVIDER_TENANT_ID`, `EPP_PROVIDER_SCOPE`, `EPP_OUTBOUND_CLIENT_ID`, and +`EPP_OUTBOUND_MI_CLIENT_ID`. `EPP_PROVIDER_AUTH_MODE=oauth` matches the Soprano adapter. +`EPP_PROVIDER_ENDPOINT` is the complete selected send URL and is not modified by the adapter. +The calling app registration and outbound user-assigned identity must share a home tenant; the +calling app must be multitenant and provisioned/authorized in the provider tenant. The app's +federated credential trusts the identity's principal ID, home-tenant v2 issuer, and +`api://AzureADTokenExchange` audience. Key Vault identity selection remains independent. + +Only the final application token is sent as `Authorization: Bearer ...`. No Soprano API ID/key, +managed-identity assertion, or incoming SAS token is forwarded. Missing settings, token-acquisition +failure, blank tokens, or tokens with 30 seconds or less remaining lifetime fail before provider HTTP; +there is no API-key fallback. Evaluation skips acquisition. A provider rejection is not retried. +Tokens are treated as opaque: the Function checks SDK expiry metadata, not custom JWT claims. +Soprano remains responsible for signature, issuer, audience, expiry, permissions, and account validation. + +Credential instances are reused for the configured tenant/application/identity; each acquisition +uses the selected scope. JavaScript and .NET pass one 2.5-second cancellation signal/token through +both exchange stages. Python uses 2.5-second connect/read inactivity timeouts, not a total deadline. +Configured SDK transport retries are disabled. Managed-identity discovery may involve additional +SDK operations; this is not an end-to-end delivery deadline. JavaScript suppresses SDK logs only +in the acquisition's asynchronous context. Python filters Azure Identity/Core/MSAL records on +configured handlers in that context; configure logging sinks before handling requests. .NET disables +credential diagnostics. Keep platform body tracing off and never log credential objects or tokens. + +When migrating from the earlier optional-JWT branch, replace `EPP_PROVIDER_APPLICATION_ID` with +`EPP_OUTBOUND_CLIENT_ID` and `EPP_PROVIDER_MI_CLIENT_ID` with `EPP_OUTBOUND_MI_CLIENT_ID`. +Remove `EPP_PROVIDER_JWT_ENABLED`; it no longer controls authentication. Reuse the exact provider +scope selected by setup and replace old base URLs with complete send URLs. These source changes +do not update deployed settings or establish provider authorization. Earlier QA4 tests of API keys +plus JWT do not validate the current Bearer-only configuration or production endpoints. + +See [Microsoft's managed-identity federation guidance](https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation-config-app-trust-managed-identity). + +Use a speech language supported by the selected Soprano endpoint and account. On QA4, an API-key voice request using `en` returned HTTP `400` with error code `400101`; the same request structure using `en-US` returned HTTP `201` with `ENROUTE` on September 15, 2026. This confirms acceptance, not handset receipt or audio quality. The adapter preserves the supplied language and does not diff --git a/dotnet/Src/DispatchEngine.cs b/dotnet/Src/DispatchEngine.cs index f0de37c..95c6caa 100644 --- a/dotnet/Src/DispatchEngine.cs +++ b/dotnet/Src/DispatchEngine.cs @@ -221,15 +221,39 @@ public sealed class DispatchEngine private readonly object _oauthLock = new(); private TokenCredential? _oauthCredential; private string? _oauthCredentialConfig; + private readonly Func _createManagedIdentity; + private readonly Func>, TokenCredential> _createOAuthCredential; public DispatchEngine(ProviderRegistry registry, ISecretResolver secrets, IHttpClientFactory httpFactory, IEnv? env = null) + : this(registry, secrets, httpFactory, env, + identity => new ManagedIdentityCredential(identity, OAuthOptions()), + (tenant, application, assertion) => new ClientAssertionCredential(tenant, application, assertion, OAuthOptions())) { } + + internal DispatchEngine(ProviderRegistry registry, ISecretResolver secrets, IHttpClientFactory httpFactory, IEnv? env, + Func createManagedIdentity, + Func>, TokenCredential> createOAuthCredential) { _registry = registry; _secrets = secrets; _httpFactory = httpFactory; _env = env ?? new ProcessEnv(); + _createManagedIdentity = createManagedIdentity; + _createOAuthCredential = createOAuthCredential; } + private static ClientAssertionCredentialOptions OAuthOptions() + { + var options = new ClientAssertionCredentialOptions { AuthorityHost = AzureAuthorityHosts.AzurePublicCloud }; + options.Retry.MaxRetries = 0; + options.Retry.NetworkTimeout = TimeSpan.FromSeconds(2.5); + options.Diagnostics.IsLoggingEnabled = false; + options.Diagnostics.IsLoggingContentEnabled = false; + return options; + } + + private static bool UsableAccessToken(AccessToken token) => + !string.IsNullOrWhiteSpace(token.Token) && token.ExpiresOn > DateTimeOffset.UtcNow.AddSeconds(30); + public async Task DispatchAsync(DispatchRequest dispatch, string requestId) { var config = AppConfig.Read(_env); @@ -327,8 +351,8 @@ private async Task ResolveCredentialAsync(AuthConfig auth, A { if (_oauthCredential is null || _oauthCredentialConfig != credentialConfig) { - var managedIdentity = new ManagedIdentityCredential(config.OutboundManagedIdentityClientId); - _oauthCredential = new ClientAssertionCredential( + var managedIdentity = _createManagedIdentity(config.OutboundManagedIdentityClientId); + _oauthCredential = _createOAuthCredential( config.ProviderTenantId, config.OutboundClientId, async cancellationToken => @@ -336,15 +360,20 @@ private async Task ResolveCredentialAsync(AuthConfig auth, A var assertion = await managedIdentity.GetTokenAsync( new TokenRequestContext(new[] { "api://AzureADTokenExchange/.default" }), cancellationToken); + if (!UsableAccessToken(assertion)) + throw new InvalidOperationException("managed identity assertion unavailable"); return assertion.Token; }); _oauthCredentialConfig = credentialConfig; } providerCredential = _oauthCredential; } + using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(2.5)); var token = await providerCredential.GetTokenAsync( new TokenRequestContext(new[] { config.ProviderScope }), - CancellationToken.None); + cancellation.Token); + if (!UsableAccessToken(token)) + throw new InvalidOperationException("provider OAuth token unavailable"); return new ProviderCredential("oauth", AccessToken: token.Token); } diff --git a/dotnet/Src/Models.cs b/dotnet/Src/Models.cs index a65a058..9eacb32 100644 --- a/dotnet/Src/Models.cs +++ b/dotnet/Src/Models.cs @@ -39,7 +39,11 @@ public sealed record TextToVoice( public override string ToString() => nameof(TextToVoice); } -public sealed record ProviderCredential(string Mode, string? Secret = null, string? Identity = null, string? AccessToken = null); +public sealed record ProviderCredential(string Mode, string? Secret = null, string? Identity = null, + [property: JsonIgnore] string? AccessToken = null) +{ + public override string ToString() => nameof(ProviderCredential); +} public sealed record ProviderHttpRequest(string Url, string Method, Dictionary Headers, string Body); diff --git a/dotnet/tests/EngineTests.cs b/dotnet/tests/EngineTests.cs index f02d8bd..2871c38 100644 --- a/dotnet/tests/EngineTests.cs +++ b/dotnet/tests/EngineTests.cs @@ -2,6 +2,7 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; +using Azure.Core; using Epp.Otp.Providers; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -19,6 +20,134 @@ public class EngineTests private const string Correlation = "private-correlation"; private const string PrivateError = "private key/provider error: +15551234567 code 918273"; + private static void ConfigureSoprano(HandlerRig rig) + { + rig.Env["EPP_PROVIDER_NAME"] = "soprano"; + rig.Env["EPP_PROVIDER_AUTH_MODE"] = "oauth"; + rig.Env["EPP_PROVIDER_CHANNEL"] = "sms"; + rig.Env["EPP_PROVIDER_ENDPOINT"] = "https://provider.example/full/send/"; + rig.Env["EPP_PROVIDER_TENANT_ID"] = "provider-tenant"; + rig.Env["EPP_PROVIDER_SCOPE"] = "api://provider/.default"; + rig.Env["EPP_OUTBOUND_CLIENT_ID"] = "calling-application"; + rig.Env["EPP_OUTBOUND_MI_CLIENT_ID"] = "outbound-identity"; + rig.Env["AZURE_CLIENT_ID"] = "different-vault-identity"; + rig.Http.Respond = _ => Task.FromResult(Json(201, "{\"status\":\"ENROUTE\"}")); + } + + [Fact] + public async Task SopranoOAuthUsesSetupIdentitiesScopeAndOneBoundedExchange() + { + var scopes = new List(); + var identities = new List(); + var applications = new List<(string Tenant, string Application)>(); + CancellationToken outerCancellation = default; + using var rig = new HandlerRig(identity => + { + identities.Add(identity); + return new TestTokenCredential((context, cancellation) => + { + Assert.Equal("api://AzureADTokenExchange/.default", Assert.Single(context.Scopes)); + Assert.Equal(outerCancellation, cancellation); + return ValueTask.FromResult(new AccessToken("private-assertion", DateTimeOffset.UtcNow.AddHours(1))); + }); + }, (tenant, application, assertion) => + { + applications.Add((tenant, application)); + return new TestTokenCredential(async (context, cancellation) => + { + Assert.True(cancellation.CanBeCanceled); + outerCancellation = cancellation; + scopes.Add(Assert.Single(context.Scopes)); + Assert.Equal("private-assertion", await assertion(cancellation)); + return new AccessToken("private-provider-token", DateTimeOffset.UtcNow.AddHours(1)); + }); + }); + ConfigureSoprano(rig); + AssertAccepted(await rig.Invoke("evaluation")); + Assert.Empty(applications); + foreach (var channel in new[] { "sms", "voice" }) + { + rig.Env["EPP_PROVIDER_CHANNEL"] = channel; + AssertAccepted(await rig.Invoke(channel: channel, deliveryOverrides: JsonSerializer.SerializeToElement(new + { + providerJwt = "FORGED-PAYLOAD", textToVoice = new { beforePasswordText = "Code", password = "001234", language = "en-US" }, + }))); + Assert.Equal("Bearer private-provider-token", rig.Http.Headers["Authorization"]); + Assert.DoesNotContain("X-MEMS-API-ID", rig.Http.Headers.Keys); + Assert.DoesNotContain("X-MEMS-API-Key", rig.Http.Headers.Keys); + Assert.DoesNotContain("FORGED", rig.Http.Body!); + } + rig.Env["EPP_PROVIDER_CHANNEL"] = "sms"; + rig.Env["EPP_PROVIDER_SCOPE"] = "api://second/.default"; + AssertAccepted(await rig.Invoke()); + Assert.Single(applications); + Assert.Equal(new[] { "api://provider/.default", "api://provider/.default", "api://second/.default" }, scopes); + rig.Env["EPP_OUTBOUND_CLIENT_ID"] = "second-application"; + AssertAccepted(await rig.Invoke()); + Assert.Equal(new[] { ("provider-tenant", "calling-application"), ("provider-tenant", "second-application") }, applications); + Assert.All(identities, identity => Assert.Equal("outbound-identity", identity)); + Assert.Equal(4, rig.Http.Calls); + Assert.Equal(0, rig.Secrets.Calls); + Assert.DoesNotContain("private-provider-token", string.Join("\n", rig.Log.Messages)); + var credential = new ProviderCredential("oauth", AccessToken: "private-provider-token"); + Assert.DoesNotContain("private-provider-token", JsonSerializer.Serialize(credential) + credential); + } + + [Theory] + [InlineData(true, "", 3600)] + [InlineData(true, "private-assertion", 5)] + [InlineData(false, " ", 3600)] + [InlineData(false, "private-token", -1)] + public async Task SopranoOAuthRejectsUnusableTokensBeforeProviderIo(bool invalidAssertion, string token, int lifetime) + { + var invalid = new AccessToken(token, DateTimeOffset.UtcNow.AddSeconds(lifetime)); + using var rig = new HandlerRig(_ => new TestTokenCredential((_, _) => ValueTask.FromResult(invalidAssertion + ? invalid : new AccessToken("assertion", DateTimeOffset.UtcNow.AddHours(1)))), + (_, _, assertion) => new TestTokenCredential(async (_, cancellation) => + { + await assertion(cancellation); + return invalid; + })); + ConfigureSoprano(rig); + AssertFailure(rig, await rig.Invoke(), 502); + Assert.Equal((0, 0), (rig.Http.Calls, rig.Secrets.Calls)); + } + + [Fact] + public async Task SopranoOAuthCancellationAndRejectionNeverFallBackOrRetry() + { + CancellationToken observed = default; + var waitForCancellation = true; + using var rig = new HandlerRig(_ => new TestTokenCredential(async (_, cancellation) => + { + if (waitForCancellation) + { + observed = cancellation; + await Task.Delay(Timeout.Infinite, cancellation); + } + return new AccessToken("assertion", DateTimeOffset.UtcNow.AddHours(1)); + }), (_, _, assertion) => new TestTokenCredential(async (_, cancellation) => + { + await assertion(cancellation); + return new AccessToken("token", DateTimeOffset.UtcNow.AddHours(1)); + })); + ConfigureSoprano(rig); + AssertFailure(rig, await rig.Invoke().WaitAsync(TimeSpan.FromSeconds(10)), 502); + Assert.True(observed.IsCancellationRequested); + Assert.Equal((0, 0), (rig.Http.Calls, rig.Secrets.Calls)); + waitForCancellation = false; + rig.Http.Respond = _ => Task.FromResult(Json(401, "{\"status\":\"REJECTED\"}")); + AssertFailure(rig, await rig.Invoke(), 401); + Assert.Equal((1, 0), (rig.Http.Calls, rig.Secrets.Calls)); + } + + private sealed class TestTokenCredential(Func> acquire) : TokenCredential + { + public override AccessToken GetToken(TokenRequestContext context, CancellationToken cancellation) => + throw new InvalidOperationException("Synchronous acquisition not expected"); + public override ValueTask GetTokenAsync(TokenRequestContext context, CancellationToken cancellation) => acquire(context, cancellation); + } + [Fact] public async Task HandlerUsesInjectedConfigAwaitsAcceptanceAndKeepsLogsPrivate() { @@ -264,7 +393,8 @@ private sealed class HandlerRig : IDisposable public TestHttp Http { get; } = new(); public TestKeys Keys { get; } = new(); public CapturingLogger Log { get; } = new(); - public HandlerRig() + public HandlerRig(Func? createIdentity = null, + Func>, TokenCredential>? createOAuth = null) { Env = new TestEnv { @@ -274,7 +404,9 @@ public HandlerRig() }; var registry = new ProviderRegistry(new IProviderAdapter[] { new InfobipProvider(), new TelesignProvider(), new SopranoProvider(), new SinchProvider() }); - _function = new SendOtp(new DispatchEngine(registry, Secrets, Http, Env), + var engine = createIdentity is null ? new DispatchEngine(registry, Secrets, Http, Env) + : new DispatchEngine(registry, Secrets, Http, Env, createIdentity, createOAuth!); + _function = new SendOtp(engine, new JweDecryptor(Keys), Env, Log); } public async Task Invoke(object? mode = null, string channel = "sms", string? tenantId = null, diff --git a/javascript/package-lock.json b/javascript/package-lock.json index 70a563b..20db06d 100644 --- a/javascript/package-lock.json +++ b/javascript/package-lock.json @@ -11,6 +11,7 @@ "@azure/functions": "^4.0.0", "@azure/identity": "^4.13.1", "@azure/keyvault-secrets": "^4.11.2", + "@azure/logger": "1.3.0", "jose": "^5.9.6" } }, diff --git a/javascript/package.json b/javascript/package.json index bacac39..88a5420 100644 --- a/javascript/package.json +++ b/javascript/package.json @@ -11,6 +11,7 @@ "@azure/functions": "^4.0.0", "@azure/identity": "^4.13.1", "@azure/keyvault-secrets": "^4.11.2", + "@azure/logger": "1.3.0", "jose": "^5.9.6" } } diff --git a/javascript/src/functions/dispatch.js b/javascript/src/functions/dispatch.js index f21970b..492d165 100644 --- a/javascript/src/functions/dispatch.js +++ b/javascript/src/functions/dispatch.js @@ -8,6 +8,8 @@ const crypto = require('crypto'); const { compactDecrypt } = require('jose'); const { ClientAssertionCredential, ManagedIdentityCredential } = require('@azure/identity'); const { SecretClient } = require('@azure/keyvault-secrets'); +const { AzureLogger } = require('@azure/logger'); +const { AsyncLocalStorage } = require('node:async_hooks'); const { readConfig } = require('./config'); const { DeliveryContext, TextToVoice } = require('./models'); @@ -165,6 +167,13 @@ let keyVaultClientConfig; const secretCache = new Map(); let oauthCredential = null; let oauthCredentialConfig; +const oauthRequest = new AsyncLocalStorage(); +let filteredOAuthLogger; + +function usableAccessToken(value) { + return typeof value?.token === 'string' && value.token.trim() + && Number.isFinite(value.expiresOnTimestamp) && value.expiresOnTimestamp > Date.now() + 30000; +} function getKeyVaultSecretClient(config) { const cacheKey = JSON.stringify([config.keyVaultUrl, config.managedIdentityClientId]); @@ -212,25 +221,40 @@ async function resolveProviderCredential(authConfiguration = {}, config) { || !config.outboundClientId || !config.outboundManagedIdentityClientId) { throw new Error('unsupported or incomplete provider authentication'); } - const credentialConfig = JSON.stringify([ - config.providerTenantId, config.outboundClientId, config.outboundManagedIdentityClientId, - ]); - if (!oauthCredential || oauthCredentialConfig !== credentialConfig) { - const assertionIdentity = new ManagedIdentityCredential(config.outboundManagedIdentityClientId); - oauthCredential = new ClientAssertionCredential( - config.providerTenantId, - config.outboundClientId, - async () => { - const assertion = await assertionIdentity.getToken('api://AzureADTokenExchange/.default'); - if (!assertion?.token) throw new Error('managed identity assertion unavailable'); - return assertion.token; - }, - ); - oauthCredentialConfig = credentialConfig; - } - const accessToken = await oauthCredential.getToken(config.providerScope); - if (!accessToken?.token) throw new Error('provider OAuth token unavailable'); - return { mode: 'oauth', accessToken: accessToken.token }; + if (AzureLogger.log !== filteredOAuthLogger) { + const log = AzureLogger.log; + filteredOAuthLogger = (...args) => { if (!oauthRequest.getStore()) log(...args); }; + AzureLogger.log = filteredOAuthLogger; + } + return oauthRequest.run(AbortSignal.timeout(2500), async () => { + try { + const credentialConfig = JSON.stringify([ + config.providerTenantId, config.outboundClientId, config.outboundManagedIdentityClientId, + ]); + if (!oauthCredential || oauthCredentialConfig !== credentialConfig) { + const assertionIdentity = new ManagedIdentityCredential({ + clientId: config.outboundManagedIdentityClientId, retryOptions: { maxRetries: 0 }, + }); + oauthCredential = new ClientAssertionCredential( + config.providerTenantId, + config.outboundClientId, + async () => { + const assertion = await assertionIdentity.getToken('api://AzureADTokenExchange/.default', + { abortSignal: oauthRequest.getStore() }); + if (!usableAccessToken(assertion)) throw new Error('managed identity assertion unavailable'); + return assertion.token; + }, + { authorityHost: 'https://login.microsoftonline.com', retryOptions: { maxRetries: 0 } }, + ); + oauthCredentialConfig = credentialConfig; + } + const accessToken = await oauthCredential.getToken(config.providerScope, { abortSignal: oauthRequest.getStore() }); + if (!usableAccessToken(accessToken)) throw new Error('provider OAuth token unavailable'); + return Object.defineProperty({ mode: 'oauth' }, 'accessToken', { value: accessToken.token }); + } catch { + throw new Error('provider OAuth token unavailable'); + } + }); } // Status mappings may restrict HTTP success, but cannot turn failed HTTP into Continue. diff --git a/javascript/test/dispatch.test.js b/javascript/test/dispatch.test.js index b1e6899..4bb2b53 100644 --- a/javascript/test/dispatch.test.js +++ b/javascript/test/dispatch.test.js @@ -8,6 +8,7 @@ const { AppConfig, readConfig } = require('../src/functions/config'); const { DeliveryContext, TextToVoice, ParsedResponse } = require('../src/functions/models'); const fixtures = require('../../tests/fixtures/contract.json'); const { inspect } = require('node:util'); +const { AzureLogger } = require('@azure/logger'); const { dispatchOtp, getProvider, resolveOutcome, outcomeToHttpStatus, parseEnvelope, parseProviderTimeout, isValidProviderUrl, contextToDispatch, resolveProviderCredential, @@ -249,9 +250,14 @@ test('missing API-key or OAuth settings and an unsafe final voice URL make zero assert.equal(fetchMock.mock.callCount(), 0); }); -test('Soprano OAuth requests the selected provider scope', async (t) => { - t.mock.method(ManagedIdentityCredential.prototype, 'getToken', async () => ({ token: 'assertion-token' })); - const providerToken = t.mock.method(ClientAssertionCredential.prototype, 'getToken', async () => ({ token: 'provider-token' })); +test('Soprano OAuth reuses setup identities and selected scope with private bounded tokens', async (t) => { + const identityToken = t.mock.method(ManagedIdentityCredential.prototype, 'getToken', async () => ({ + token: 'assertion-token', expiresOnTimestamp: Date.now() + 3600000, + })); + const providerToken = t.mock.method(ClientAssertionCredential.prototype, 'getToken', async function () { + assert.equal(await this.getAssertion(), 'assertion-token'); + return { token: 'provider-token', expiresOnTimestamp: Date.now() + 3600000 }; + }); const config = readConfig({ EPP_PROVIDER_TENANT_ID: '11111111-1111-1111-1111-111111111111', EPP_PROVIDER_SCOPE: 'api://provider/.default', @@ -259,6 +265,55 @@ test('Soprano OAuth requests the selected provider scope', async (t) => { EPP_OUTBOUND_MI_CLIENT_ID: '33333333-3333-3333-3333-333333333333', }); const credential = await resolveProviderCredential({ mode: 'oauth' }, config); - assert.deepEqual(credential, { mode: 'oauth', accessToken: 'provider-token' }); + assert.equal(credential.accessToken, 'provider-token'); + assert.equal(JSON.stringify(credential), '{"mode":"oauth"}'); assert.equal(providerToken.mock.calls[0].arguments[0], 'api://provider/.default'); + assert.equal(identityToken.mock.calls[0].arguments[0], 'api://AzureADTokenExchange/.default'); + const signal = providerToken.mock.calls[0].arguments[1].abortSignal; + assert.ok(signal instanceof AbortSignal); + assert.equal(identityToken.mock.calls[0].arguments[1].abortSignal, signal); + await resolveProviderCredential({ mode: 'oauth' }, { ...config, providerScope: 'api://another/.default' }); + assert.equal(providerToken.mock.calls[1].this, providerToken.mock.calls[0].this); + assert.equal(providerToken.mock.calls[1].arguments[0], 'api://another/.default'); + for (const property of ['providerTenantId', 'outboundClientId', 'outboundManagedIdentityClientId']) { + await resolveProviderCredential({ mode: 'oauth' }, { ...config, + [property]: '44444444-4444-4444-4444-444444444444' }); + assert.notEqual(providerToken.mock.calls.at(-1).this, providerToken.mock.calls[0].this); + } + for (const stage of ['token', 'assertion']) { + for (const invalid of [null, { token: '' }, { token: ' ' }, { token: false }, + { token: 'stale', expiresOnTimestamp: Date.now() + 10000 }, { token: 'missing-expiry' }]) { + const method = stage === 'token' ? providerToken : identityToken; + method.mock.mockImplementation(async () => invalid); + if (stage === 'assertion') providerToken.mock.mockImplementation(async function () { + await this.getAssertion(); + return { token: 'provider-token', expiresOnTimestamp: Date.now() + 3600000 }; + }); + await assert.rejects(resolveProviderCredential({ mode: 'oauth' }, config), /^Error: provider OAuth token unavailable$/); + } + } +}); + +test('Soprano OAuth suppresses SDK diagnostics only during token acquisition', async (t) => { + const entries = []; + t.mock.method(AzureLogger, 'log', (...args) => entries.push(args)); + let release; + const waiting = new Promise(resolve => { release = resolve; }); + t.mock.method(ClientAssertionCredential.prototype, 'getToken', async () => { + AzureLogger.log('PRIVATE-TOKEN-AND-ACCOUNT'); + await waiting; + AzureLogger.log('PRIVATE-SDK-FAILURE'); + throw new Error('PRIVATE-TOKEN-EXCEPTION'); + }); + const pending = resolveProviderCredential({ mode: 'oauth' }, readConfig({ + EPP_PROVIDER_TENANT_ID: '11111111-1111-1111-1111-111111111111', + EPP_PROVIDER_SCOPE: 'api://provider/.default', + EPP_OUTBOUND_CLIENT_ID: '22222222-2222-2222-2222-222222222222', + EPP_OUTBOUND_MI_CLIENT_ID: '33333333-3333-3333-3333-333333333333', + })); + AzureLogger.log('unrelated request'); + release(); + await assert.rejects(pending, /^Error: provider OAuth token unavailable$/); + AzureLogger.log('after acquisition'); + assert.deepEqual(entries, [['unrelated request'], ['after acquisition']]); }); diff --git a/javascript/test/sendotp.test.js b/javascript/test/sendotp.test.js index 05d0d3c..74050d3 100644 --- a/javascript/test/sendotp.test.js +++ b/javascript/test/sendotp.test.js @@ -5,7 +5,7 @@ const assert = require('node:assert/strict'); const crypto = require('node:crypto'); const Module = require('node:module'); const { CompactEncrypt } = require('jose'); -const { ClientAssertionCredential } = require('@azure/identity'); +const { ClientAssertionCredential, ManagedIdentityCredential } = require('@azure/identity'); const { SecretClient } = require('@azure/keyvault-secrets'); const fixtures = require('../../tests/fixtures/contract.json'); @@ -34,6 +34,7 @@ let fetchMock; let getSecret; let logs; let warnings; +let getToken; beforeEach(() => { savedEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]])); for (const key of envKeys) delete process.env[key]; @@ -45,7 +46,13 @@ beforeEach(() => { EPP_PROVIDER_SCOPE: 'api://provider/.default', EPP_OUTBOUND_CLIENT_ID: '22222222-2222-2222-2222-222222222222', EPP_OUTBOUND_MI_CLIENT_ID: '33333333-3333-3333-3333-333333333333' }); - mock.method(ClientAssertionCredential.prototype, 'getToken', async () => ({ token: 'PRIVATE-OAUTH-TOKEN' })); + mock.method(ManagedIdentityCredential.prototype, 'getToken', async () => ({ + token: 'PRIVATE-ASSERTION', expiresOnTimestamp: Date.now() + 3600000, + })); + getToken = mock.method(ClientAssertionCredential.prototype, 'getToken', async function () { + assert.equal(await this.getAssertion(), 'PRIVATE-ASSERTION'); + return { token: 'PRIVATE-OAUTH-TOKEN', expiresOnTimestamp: Date.now() + 3600000 }; + }); getSecret = mock.method(SecretClient.prototype, 'getSecret', async () => ({ value: 'PRIVATE-API-KEY' })); fetchMock = mock.method(global, 'fetch', async () => ({ ok: true, status: 201, text: async () => JSON.stringify({ status: 'ENROUTE', id: 'PRIVATE-ID', description: 'PRIVATE-STATUS' }) })); @@ -159,6 +166,18 @@ test('evaluation decrypts without provider config or I/O and checks the advisory assert.deepEqual(warnings, expectedKeyId === 'private-kid' ? [['encryption_key_id_mismatch']] : []); } assert.deepEqual([getSecret.mock.callCount(), fetchMock.mock.callCount()], [0, 0]); + assert.equal(getToken.mock.callCount(), 0); +}); + +test('Soprano OAuth failures never fall back to keys or forward an inbound token', async () => { + for (const failure of [async () => { throw new Error('PRIVATE-TOKEN-ERROR'); }, + async () => ({ token: 'PRIVATE-EXPIRED', expiresOnTimestamp: Date.now() - 1 })]) { + getToken.mock.mockImplementation(failure); + assertFailure(await invoke(await envelope({}, { ...delivery, providerJwt: 'FORGED-PAYLOAD' }), + { authorization: 'Bearer FORGED-INBOUND' }), 502); + assert.doesNotMatch(JSON.stringify([logs, warnings]), /PRIVATE|FORGED/); + } + assert.deepEqual([getSecret.mock.callCount(), fetchMock.mock.callCount()], [0, 0]); }); test('SMS/voice preserve content and correlation without reflecting headers or logging PII', async () => { diff --git a/python/src/dispatch.py b/python/src/dispatch.py index 7b765ab..f65c0d1 100644 --- a/python/src/dispatch.py +++ b/python/src/dispatch.py @@ -2,7 +2,12 @@ import base64 import json +import logging +import math import os +import time +from contextvars import ContextVar +from threading import Lock from urllib.parse import urlsplit import requests @@ -22,6 +27,23 @@ BLOCK = "Block" STEP_UP = "StepUp" +_oauth_request = ContextVar("provider_oauth_request", default=False) + + +class _OAuthLogFilter(logging.Filter): + def filter(self, record): + return not (_oauth_request.get() and record.name.startswith(("azure.identity", "azure.core", "msal"))) + + +_oauth_log_filter = _OAuthLogFilter() + + +def _usable_access_token(value): + token = getattr(value, "token", None) + expiry = getattr(value, "expires_on", None) + return (isinstance(token, str) and bool(token.strip()) and type(expiry) in (int, float) + and math.isfinite(expiry) and expiry > time.time() + 30) + def resolve_outcome(manifest, parsed: ParsedResponse): mapping = manifest["response_mapping"] @@ -246,6 +268,7 @@ def __init__(self, registry, secrets, env=None): self.env = env if env is not None else os.environ self._oauth_credential = None self._oauth_credential_config = None + self._oauth_lock = Lock() def dispatch(self, dispatch, request_id): config = read_config(self.env) @@ -358,30 +381,42 @@ def _resolve_credential(self, auth, config): config.outbound_client_id, config.outbound_managed_identity_client_id, )): raise ValueError("unsupported or incomplete provider authentication") - credential_config = ( - config.provider_tenant_id, - config.outbound_client_id, - config.outbound_managed_identity_client_id, - ) - if self._oauth_credential is None or self._oauth_credential_config != credential_config: - assertion_identity = ManagedIdentityCredential(client_id=config.outbound_managed_identity_client_id) - - def get_assertion(): - token = assertion_identity.get_token("api://AzureADTokenExchange/.default") - if not token or not token.token: - raise ValueError("managed identity assertion unavailable") - return token.token - - self._oauth_credential = ClientAssertionCredential( - tenant_id=config.provider_tenant_id, - client_id=config.outbound_client_id, - func=get_assertion, + for logger in (logging.getLogger(), *logging.Logger.manager.loggerDict.copy().values()): + if isinstance(logger, logging.Logger): + for handler in logger.handlers: + if _oauth_log_filter not in handler.filters: + handler.addFilter(_oauth_log_filter) + context_token = _oauth_request.set(True) + try: + credential_config = ( + config.provider_tenant_id, config.outbound_client_id, config.outbound_managed_identity_client_id, ) - self._oauth_credential_config = credential_config - token = self._oauth_credential.get_token(config.provider_scope) - if not token or not token.token: - raise ValueError("provider OAuth token unavailable") - return {"mode": "oauth", "access_token": token.token} + with self._oauth_lock: + if self._oauth_credential is None or self._oauth_credential_config != credential_config: + assertion_identity = ManagedIdentityCredential(client_id=config.outbound_managed_identity_client_id, + retry_total=0, connection_timeout=2.5, read_timeout=2.5, logging_enable=False) + + def get_assertion(): + token = assertion_identity.get_token("api://AzureADTokenExchange/.default", logging_enable=False) + if not _usable_access_token(token): + raise ValueError("managed identity assertion unavailable") + return token.token + + self._oauth_credential = ClientAssertionCredential( + tenant_id=config.provider_tenant_id, client_id=config.outbound_client_id, func=get_assertion, + authority="https://login.microsoftonline.com", retry_total=0, + connection_timeout=2.5, read_timeout=2.5, logging_enable=False, + ) + self._oauth_credential_config = credential_config + credential = self._oauth_credential + token = credential.get_token(config.provider_scope, logging_enable=False) + if not _usable_access_token(token): + raise ValueError("provider OAuth token unavailable") + return {"mode": "oauth", "access_token": token.token} + except Exception: + raise ValueError("provider OAuth token unavailable") from None + finally: + _oauth_request.reset(context_token) def _fail_body(self, provider, channel, reason, dispatch, request_id): return {"status": "failed", "outcome": "Fail", "provider": provider, "channel": channel, "reason": reason, "correlationId": dispatch.correlation_id, "messageId": dispatch.message_id, "requestId": request_id} diff --git a/python/tests/test_engine.py b/python/tests/test_engine.py index 818b8ec..50da4aa 100644 --- a/python/tests/test_engine.py +++ b/python/tests/test_engine.py @@ -1,4 +1,10 @@ import json +import io +import logging +import time +from concurrent.futures import ThreadPoolExecutor +from threading import Event +from types import SimpleNamespace from unittest.mock import Mock import pytest @@ -37,6 +43,109 @@ def test_missing_oauth_configuration_never_sends(engine): dispatch_module.requests.request.assert_not_called() +def _oauth_settings(): + return {"EPP_PROVIDER_NAME": "soprano", "EPP_PROVIDER_AUTH_MODE": "oauth", "EPP_PROVIDER_CHANNEL": "sms", + "EPP_PROVIDER_ENDPOINT": "https://provider.example/full/sms/url/", + "EPP_PROVIDER_TENANT_ID": "provider-tenant", "EPP_PROVIDER_SCOPE": "api://provider/.default", + "EPP_OUTBOUND_CLIENT_ID": "calling-app", "EPP_OUTBOUND_MI_CLIENT_ID": "outbound-identity", + "AZURE_CLIENT_ID": "different-vault-identity"} + + +def test_soprano_oauth_uses_setup_settings_and_rejects_unusable_tokens(engine, monkeypatch): + engine.env = _oauth_settings() + engine._resolve_credential = DispatchEngine._resolve_credential.__get__(engine, DispatchEngine) + assertion = SimpleNamespace(token="private-assertion", expires_on=time.time() + 3600) + access = SimpleNamespace(token="private-token", expires_on=time.time() + 3600) + managed = Mock(get_token=Mock(side_effect=lambda *args, **kwargs: assertion)) + identity_factory = Mock(return_value=managed) + clients = [] + + def create_client(**kwargs): + assert kwargs["tenant_id"] == engine.env["EPP_PROVIDER_TENANT_ID"] + assert kwargs["client_id"] == engine.env["EPP_OUTBOUND_CLIENT_ID"] + assert kwargs["retry_total"] == 0 and kwargs["connection_timeout"] == kwargs["read_timeout"] == 2.5 + assert kwargs["logging_enable"] is False + + def get_token(*args, **options): + assert args == (engine.env["EPP_PROVIDER_SCOPE"],) and options == {"logging_enable": False} + assert kwargs["func"]() == "private-assertion" + return access + + client = Mock(get_token=Mock(side_effect=get_token)) + clients.append(client) + return client + + monkeypatch.setattr(dispatch_module, "ManagedIdentityCredential", identity_factory) + monkeypatch.setattr(dispatch_module, "ClientAssertionCredential", create_client) + dispatch_module.requests.request.return_value = Mock(status_code=201, json=Mock(return_value={"status": "ENROUTE"})) + for scope in ("api://provider/.default", "api://second/.default"): + engine.env["EPP_PROVIDER_SCOPE"] = scope + assert engine.dispatch(_request(), "request")[0] == 200 + assert len(clients) == 1 + sent = dispatch_module.requests.request.call_args + assert sent.args[1] == engine.env["EPP_PROVIDER_ENDPOINT"] + assert sent.kwargs["headers"] == {"Content-Type": "application/json", "Accept": "application/json", + "Authorization": "Bearer private-token"} + identity_factory.assert_called_once_with(client_id="outbound-identity", retry_total=0, + connection_timeout=2.5, read_timeout=2.5, logging_enable=False) + managed.get_token.assert_called_with("api://AzureADTokenExchange/.default", logging_enable=False) + engine.env["EPP_OUTBOUND_CLIENT_ID"] = "second-calling-app" + assert engine.dispatch(_request(), "request")[0] == 200 + assert len(clients) == 2 + dispatch_module.requests.request.reset_mock() + for stage in ("access", "assertion"): + for invalid in (None, SimpleNamespace(token=""), SimpleNamespace(token=" "), + SimpleNamespace(token="private-token"), + SimpleNamespace(token="private-token", expires_on=time.time() + 5)): + if stage == "access": + access = invalid + else: + access = SimpleNamespace(token="private-token", expires_on=time.time() + 3600) + assertion = invalid + status, body = engine.dispatch(_request(), "request") + assert status == 502 and body["reason"] == "provider credential unavailable" + assert "private" not in json.dumps(body) + engine.secrets.resolve.assert_not_called() + dispatch_module.requests.request.assert_not_called() + + +def test_soprano_oauth_sdk_logs_stay_private_without_muting_other_requests(engine, monkeypatch, caplog): + engine.env = _oauth_settings() + engine._resolve_credential = DispatchEngine._resolve_credential.__get__(engine, DispatchEngine) + started, release = Event(), Event() + logger = logging.getLogger("azure.identity.test_setup_oauth") + output = io.StringIO() + handler = logging.StreamHandler(output) + logger.addHandler(handler) + + def fail(*args, **kwargs): + logger.warning("PRIVATE-SDK-TOKEN") + started.set() + assert release.wait(5) + logger.warning("PRIVATE-ACCOUNT-ERROR") + raise RuntimeError("PRIVATE-TOKEN-EXCEPTION") + + monkeypatch.setattr(dispatch_module, "ManagedIdentityCredential", Mock()) + monkeypatch.setattr(dispatch_module, "ClientAssertionCredential", Mock(return_value=Mock(get_token=fail))) + try: + with ThreadPoolExecutor(max_workers=1) as pool: + pending = pool.submit(engine.dispatch, _request(), "request") + try: + assert started.wait(5) + logger.warning("unrelated request") + finally: + release.set() + status, body = pending.result(timeout=5) + assert status == 502 and body["reason"] == "provider credential unavailable" + logger.warning("after acquisition") + assert "PRIVATE" not in output.getvalue() + caplog.text + json.dumps(body) + assert "unrelated request" in output.getvalue() and "after acquisition" in output.getvalue() + engine.secrets.resolve.assert_not_called() + dispatch_module.requests.request.assert_not_called() + finally: + logger.removeHandler(handler) + + def test_soprano_voice_payload_uses_oauth(engine): engine.env["EPP_PROVIDER_CHANNEL"] = "voice" speech = {"beforePasswordText": "Your code is", "password": "001234", "language": "en-US"}