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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
25 changes: 25 additions & 0 deletions src/api/user-agent-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `""`
Expand Down
2 changes: 2 additions & 0 deletions src/api/user-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@
return {
allowLegacyNotifications: false,
authorizationHa1: "",
authorizationJwt: undefined as unknown as () => string,
authorizationPassword: "",
authorizationUsername: "",
delegate: {},
Expand Down Expand Up @@ -711,6 +712,7 @@
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,

Check warning on line 715 in src/api/user-agent.ts

View workflow job for this annotation

GitHub Actions / build (18.x)

Forbidden non-null assertion

Check warning on line 715 in src/api/user-agent.ts

View workflow job for this annotation

GitHub Actions / build (16.x)

Forbidden non-null assertion
transportAccessor: () => this.transport
};

Expand Down
17 changes: 17 additions & 0 deletions src/core/user-agent-core/user-agent-core-configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>` 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.
Expand Down
27 changes: 27 additions & 0 deletions src/core/user-agents/user-agent-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down Expand Up @@ -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;
Expand Down
223 changes: 223 additions & 0 deletions test/spec/api/registration-jwt.spec.ts
Original file line number Diff line number Diff line change
@@ -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<RegistererState>;

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();
});
});
});
});
Loading