diff --git a/package.json b/package.json index 6cbe5beb9..5158e6831 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "node": ">=10.0" }, "scripts": { + "prepare": "npm run generate-grammar && npm run build-lib", "prebuild": "npm run lint", "build-bundles": "npm run build-reg-bundle && npm run build-min-bundle && npm run copy-dist-files", "build-reg-bundle": "webpack --progress --config build/webpack.config.cjs --env buildType=reg", diff --git a/src/api/user-agent-options.ts b/src/api/user-agent-options.ts index 5eb037d72..4796b1ba0 100644 --- a/src/api/user-agent-options.ts +++ b/src/api/user-agent-options.ts @@ -45,6 +45,31 @@ export interface UserAgentOptions { */ authorizationHa1?: string; + /** + * JWT authorization factory. + * @remarks + * A callback invoked synchronously before each outgoing SIP request to + * obtain the current Bearer token for the `Authorization` header. + * + * The callback is responsible for token caching and proactive refresh. + * The SIP stack calls it as a pure getter and does not manage token + * lifecycle. When provided, takes precedence over digest authentication. + * + * If the server responds with 401, the callback is invoked once more, + * allowing the implementation to supply a refreshed token before the + * request is retried. + * + * @example + * ```typescript + * const ua = new UserAgent({ + * authorizationJwt: () => myTokenStore.currentToken, + * uri: UserAgent.makeURI("sip:alice@example.com"), + * ... + * }); + * ``` + */ + authorizationJwt?: () => string; + /** * Authorization password. * @defaultValue `""` diff --git a/src/api/user-agent.ts b/src/api/user-agent.ts index 1dd29dd3f..a07cc9065 100644 --- a/src/api/user-agent.ts +++ b/src/api/user-agent.ts @@ -256,6 +256,7 @@ export class UserAgent { return { allowLegacyNotifications: false, authorizationHa1: "", + authorizationJwt: undefined as unknown as () => string, authorizationPassword: "", authorizationUsername: "", delegate: {}, @@ -711,6 +712,7 @@ export class UserAgent { const ha1 = this.options.authorizationHa1 ? this.options.authorizationHa1 : undefined; return new DigestAuthentication(this.getLoggerFactory(), ha1, username, password); }, + authorizationJwtFactory: this.options.authorizationJwt ? () => this.options.authorizationJwt!() : undefined, transportAccessor: () => this.transport }; diff --git a/src/core/user-agent-core/user-agent-core-configuration.ts b/src/core/user-agent-core/user-agent-core-configuration.ts index b4e7af1e4..1f1f7cc15 100644 --- a/src/core/user-agent-core/user-agent-core-configuration.ts +++ b/src/core/user-agent-core/user-agent-core-configuration.ts @@ -104,6 +104,23 @@ export interface UserAgentCoreConfiguration { */ authenticationFactory(): DigestAuthentication | undefined; + /** + * JWT authorization factory. + * @remarks + * If defined, called synchronously before each outgoing SIP request to + * obtain the current Bearer token. The returned value is set as the + * `Authorization: Bearer ` header on the outgoing request. + * + * When provided, takes precedence over digest authentication for the + * initial request attempt. The implementation is responsible for token + * caching and proactive refresh; the SIP stack treats this as a pure + * getter and does not manage token lifecycle. + * + * If the server responds with 401, the factory is called once more to + * allow the implementation to return a refreshed token before retrying. + */ + authorizationJwtFactory?(): string; + /** * DEPRECATED: This is a hack to get around `Transport` * requiring the `UA` to start for construction. diff --git a/src/core/user-agents/user-agent-client.ts b/src/core/user-agents/user-agent-client.ts index 88ce2b24b..2a03d3c62 100644 --- a/src/core/user-agents/user-agent-client.ts +++ b/src/core/user-agents/user-agent-client.ts @@ -50,6 +50,11 @@ export class UserAgentClient implements OutgoingRequest { public delegate?: OutgoingRequestDelegate ) { this.logger = this.loggerFactory.getLogger("sip.user-agent-client"); + // JWT: proactively set Authorization header before the transaction fires. + if (this.core.configuration.authorizationJwtFactory) { + const token = this.core.configuration.authorizationJwtFactory(); + message.setHeader("authorization", `Bearer ${token}`); + } this.init(); } @@ -189,6 +194,28 @@ export class UserAgentClient implements OutgoingRequest { return true; } + // JWT: if a JWT factory is configured, retry once with a fresh token. + // The factory is responsible for returning an updated token on the second + // call (e.g. after proactive refresh triggered by the 401 event). + if (this.core.configuration.authorizationJwtFactory) { + if (this.challenged) { + this.logger.warn(statusCode + " with JWT authorization, already retried, giving up"); + return true; + } + this.challenged = true; + const token = this.core.configuration.authorizationJwtFactory(); + const authorizationHeaderName = statusCode === 401 ? "authorization" : "proxy-authorization"; + let cseq = (this.message.cseq += 1); + if (dialog && dialog.localSequenceNumber) { + dialog.incrementLocalSequenceNumber(); + cseq = this.message.cseq = dialog.localSequenceNumber; + } + this.message.setHeader("cseq", cseq + " " + this.message.method); + this.message.setHeader(authorizationHeaderName, `Bearer ${token}`); + this.init(); + return false; + } + // Get and parse the appropriate WWW-Authenticate or Proxy-Authenticate header. // eslint-disable-next-line @typescript-eslint/no-explicit-any let challenge: any; diff --git a/test/spec/api/registration-jwt.spec.ts b/test/spec/api/registration-jwt.spec.ts new file mode 100644 index 000000000..6075c7a23 --- /dev/null +++ b/test/spec/api/registration-jwt.spec.ts @@ -0,0 +1,223 @@ +import { Registerer, RegistererState } from "../../../lib/api/index.js"; +import { EmitterSpy, makeEmitterSpy } from "../../support/api/emitter-spy.js"; +import { connectUserFake, makeUserFake, UserFake } from "../../support/api/user-fake.js"; +import { soon } from "../../support/api/utils.js"; + +const SIP_REGISTER = [jasmine.stringMatching(/^REGISTER/)]; +const SIP_200 = [jasmine.stringMatching(/^SIP\/2.0 200/)]; +const SIP_401 = [jasmine.stringMatching(/^SIP\/2.0 401/)]; + +/** + * JWT Authorization - Registration Integration Tests + */ + +describe("API Registration JWT Authorization", () => { + let alice: UserFake; + let registrar: UserFake; + let registerer: Registerer; + let registererStateSpy: EmitterSpy; + + function resetSpies(): void { + alice.transportReceiveSpy.calls.reset(); + alice.transportSendSpy.calls.reset(); + registererStateSpy.calls.reset(); + } + + afterEach(async () => { + return alice.userAgent + .stop() + .then(() => registrar.userAgent.stop()) + .then(() => jasmine.clock().uninstall()); + }); + + describe("Alice creates a UserAgent with authorizationJwt factory", () => { + let jwtFactory: jasmine.Spy<() => string>; + + beforeEach(async () => { + jasmine.clock().install(); + jwtFactory = jasmine.createSpy("jwtFactory").and.returnValue("initial-token"); + alice = await makeUserFake("alice", "example.com", "Alice", { + authorizationJwt: jwtFactory + }); + registrar = await makeUserFake(undefined, "example.com", "Registrar"); + connectUserFake(alice, registrar); + registerer = new Registerer(alice.userAgent); + registererStateSpy = makeEmitterSpy(registerer.stateChange, alice.userAgent.getLogger("Alice")); + await soon(); + }); + + describe("Alice register(), registrar responds 200", () => { + let authorizationHeaderValue: string | undefined; + + beforeEach(async () => { + resetSpies(); + registrar.userAgent.delegate = { + onRegisterRequest: (request): void => { + authorizationHeaderValue = request.message.getHeader("authorization"); + const contact = request.message.parseHeader("contact"); + request.accept({ extraHeaders: [`Contact: ${contact}`], statusCode: 200 }); + } + }; + registerer.register(); + await alice.transport.waitReceived(); // 200 + }); + + it("her ua should send a REGISTER", () => { + expect(alice.transportSendSpy).toHaveBeenCalledTimes(1); + expect(alice.transportSendSpy.calls.argsFor(0)).toEqual(SIP_REGISTER); + }); + + it("the REGISTER should carry Authorization: Bearer with the token from the factory", () => { + expect(authorizationHeaderValue).toEqual("Bearer initial-token"); + }); + + it("her ua should receive 200", () => { + expect(alice.transportReceiveSpy).toHaveBeenCalledTimes(1); + expect(alice.transportReceiveSpy.calls.argsFor(0)).toEqual(SIP_200); + }); + + it("the jwt factory should have been called once", () => { + expect(jwtFactory).toHaveBeenCalledTimes(1); + }); + + it("her registerer state should transition to 'registered'", () => { + expect(registererStateSpy).toHaveBeenCalledTimes(1); + expect(registererStateSpy.calls.argsFor(0)).toEqual([RegistererState.Registered]); + }); + }); + + describe("Alice register(), registrar responds 401 then 200 (token refresh scenario)", () => { + let requestCount: number; + let authorizationHeaderOnFirstRequest: string | undefined; + let authorizationHeaderOnRetry: string | undefined; + + beforeEach(async () => { + requestCount = 0; + jwtFactory.and.callFake(() => `token-${requestCount + 1}`); + + registrar.userAgent.delegate = { + onRegisterRequest: (request): void => { + requestCount++; + if (requestCount === 1) { + // First attempt: capture header, reject with 401 + authorizationHeaderOnFirstRequest = request.message.getHeader("authorization"); + request.reject({ + statusCode: 401, + extraHeaders: [`WWW-Authenticate: Bearer realm="example.com"`] + }); + } else { + // Retry: capture header, accept + authorizationHeaderOnRetry = request.message.getHeader("authorization"); + const contact = request.message.parseHeader("contact"); + request.accept({ extraHeaders: [`Contact: ${contact}`], statusCode: 200 }); + } + } + }; + + resetSpies(); + registerer.register(); + await alice.transport.waitReceived(); // 401 + await alice.transport.waitReceived(); // 200 + }); + + it("her ua should send two REGISTERs", () => { + expect(alice.transportSendSpy).toHaveBeenCalledTimes(2); + expect(alice.transportSendSpy.calls.argsFor(0)).toEqual(SIP_REGISTER); + expect(alice.transportSendSpy.calls.argsFor(1)).toEqual(SIP_REGISTER); + }); + + it("her ua should receive 401 then 200", () => { + expect(alice.transportReceiveSpy).toHaveBeenCalledTimes(2); + expect(alice.transportReceiveSpy.calls.argsFor(0)).toEqual(SIP_401); + expect(alice.transportReceiveSpy.calls.argsFor(1)).toEqual(SIP_200); + }); + + it("the first REGISTER should carry the initial token", () => { + expect(authorizationHeaderOnFirstRequest).toEqual("Bearer token-1"); + }); + + it("the retry REGISTER should carry a fresh token from the factory", () => { + expect(authorizationHeaderOnRetry).toEqual("Bearer token-2"); + }); + + it("the jwt factory should have been called twice", () => { + // Once before initial send, once before the retry + expect(jwtFactory).toHaveBeenCalledTimes(2); + }); + + it("her registerer state should transition to 'registered'", () => { + expect(registererStateSpy).toHaveBeenCalledTimes(1); + expect(registererStateSpy.calls.argsFor(0)).toEqual([RegistererState.Registered]); + }); + }); + + describe("Alice register(), registrar always responds 401 (no infinite loop)", () => { + beforeEach(async () => { + registrar.userAgent.delegate = { + onRegisterRequest: (request): void => { + request.reject({ + statusCode: 401, + extraHeaders: [`WWW-Authenticate: Bearer realm="example.com"`] + }); + } + }; + + resetSpies(); + registerer.register(); + await alice.transport.waitReceived(); // first 401 + await alice.transport.waitReceived(); // second 401 — no more retries + }); + + it("her ua should send exactly two REGISTERs and then stop", () => { + expect(alice.transportSendSpy).toHaveBeenCalledTimes(2); + expect(alice.transportSendSpy.calls.argsFor(0)).toEqual(SIP_REGISTER); + expect(alice.transportSendSpy.calls.argsFor(1)).toEqual(SIP_REGISTER); + }); + + it("her ua should receive two 401 responses", () => { + expect(alice.transportReceiveSpy).toHaveBeenCalledTimes(2); + expect(alice.transportReceiveSpy.calls.argsFor(0)).toEqual(SIP_401); + expect(alice.transportReceiveSpy.calls.argsFor(1)).toEqual(SIP_401); + }); + + it("the jwt factory should have been called twice", () => { + expect(jwtFactory).toHaveBeenCalledTimes(2); + }); + + it("her registerer state should transition to 'unregistered'", () => { + expect(registererStateSpy).toHaveBeenCalledTimes(1); + expect(registererStateSpy.calls.argsFor(0)).toEqual([RegistererState.Unregistered]); + }); + }); + + describe("Alice register() without authorizationJwt does NOT send Authorization header", () => { + // Verify that the feature is opt-in and does not affect users without the option + let aliceNoJwt: UserFake; + let authorizationHeaderValue: string | undefined; + + beforeEach(async () => { + aliceNoJwt = await makeUserFake("alice2", "example.com", "AliceNoJwt"); + const registrar2 = await makeUserFake(undefined, "example.com", "Registrar2"); + connectUserFake(aliceNoJwt, registrar2); + const registerer2 = new Registerer(aliceNoJwt.userAgent); + + registrar2.userAgent.delegate = { + onRegisterRequest: (request): void => { + authorizationHeaderValue = request.message.getHeader("authorization"); + const contact = request.message.parseHeader("contact"); + request.accept({ extraHeaders: [`Contact: ${contact}`], statusCode: 200 }); + } + }; + + registerer2.register(); + await aliceNoJwt.transport.waitReceived(); + await aliceNoJwt.userAgent.stop(); + await registrar2.userAgent.stop(); + }); + + it("the REGISTER should not carry an Authorization header", () => { + expect(authorizationHeaderValue).toBeUndefined(); + }); + }); + }); +});