Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -598,18 +598,31 @@ describe('PairingAuthorityIntegration', () => {

expect(integration.state).toBe(AuthorityState.Failed);
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({ errno: OAUTH_ERRORS.INVALID_PARAMETER.errno })
expect.objectContaining({
errno: OAUTH_ERRORS.INVALID_PARAMETER.errno,
})
);
});

// Rules beyond presence live in pairing-request-validation.test.ts; these
// two only prove the authority is wired to the validator rather than to
// its own truthiness checks.
it.each([
{ label: 'a client that cannot pair', field: 'client_id', value: '0123456789abcdef' },
{ label: 'a PKCE method other than S256', field: 'code_challenge_method', value: 'plain' },
{
label: 'a client that cannot pair',
field: 'client_id',
value: '0123456789abcdef',
},
{
label: 'a PKCE method other than S256',
field: 'code_challenge_method',
value: 'plain',
},
])('fails on $label', ({ field, value }) => {
emit('remote:pair:supp:request', { ...MOCK_SUPP_REQUEST, [field]: value });
emit('remote:pair:supp:request', {
...MOCK_SUPP_REQUEST,
[field]: value,
});

expect(integration.state).toBe(AuthorityState.Failed);
});
Expand Down Expand Up @@ -758,6 +771,40 @@ describe('PairingAuthorityIntegration', () => {
});
});

describe('cancel', () => {
// The supplicant cannot tell a closed channel from an expired one, so the
// notice has to get out while the channel is still up.
it('tells the supplicant before closing the channel', async () => {
const integration = createIntegration();
await integration.createChannel();

await integration.cancel();

expect(mockChannelSend).toHaveBeenCalledWith('pair:auth:cancel', {});
expect(mockChannelSend.mock.invocationCallOrder[0]).toBeLessThan(
mockChannelClose.mock.invocationCallOrder[0]
);
});

it('closes the channel when the notice cannot be sent', async () => {
const integration = createIntegration();
await integration.createChannel();
mockChannelSend.mockRejectedValue(new Error('channel server gone'));

await integration.cancel();

expect(mockChannelClose).toHaveBeenCalled();
expect(integration.hasChannel()).toBe(false);
});

it('resolves when there is no channel to cancel', async () => {
const integration = createIntegration();

await expect(integration.cancel()).resolves.toBeUndefined();
expect(mockChannelSend).not.toHaveBeenCalled();
});
});

describe('destroy', () => {
it('cleans up timers and callbacks', async () => {
const integration = createIntegration();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ export class PairingAuthorityIntegration extends OAuthWebIntegration {

async createChannel(): Promise<void> {
if (this._channel) {
console.warn('Pairing channel already exists!')
console.warn('Pairing channel already exists!');
return;
}

Expand Down Expand Up @@ -185,7 +185,10 @@ export class PairingAuthorityIntegration extends OAuthWebIntegration {

private setState(state: AuthorityState): void {
this._state = state;
console.info('Emitting pairing authority state change event.', {id: this._iid, state: this.state});
console.info('Emitting pairing authority state change event.', {
id: this._iid,
state: this.state,
});
this.onStateChange?.(state);
}

Expand Down Expand Up @@ -261,9 +264,10 @@ export class PairingAuthorityIntegration extends OAuthWebIntegration {

this._supRequest = validation.request;

this._channel.send('pair:auth:metadata', {})
.then(()=>{
this.setState(AuthorityState.WaitingForAuthorizations);
this._channel
.send('pair:auth:metadata', {})
.then(() => {
this.setState(AuthorityState.WaitingForAuthorizations);
})
.catch((err) => {
console.warn('Error sending pair:auth:metadata');
Expand Down Expand Up @@ -485,18 +489,18 @@ export class PairingAuthorityIntegration extends OAuthWebIntegration {
code_challenge_method: supRequest.code_challenge_method,
keys_jwk: supRequest.keys_jwk,
scope: supRequest.scope,
state: supRequest.state
state: supRequest.state,
});

if (!result?.code || !result.state) {
throw new Error('Failed to finalize oauth pair!');
}
console.info('OAuth pair finish success!')
console.info('OAuth pair finish success!');

await this._channel.send('pair:auth:authorize', {
code: result.code,
state: result.state
})
state: result.state,
});
} catch (err) {
// pairOauthFinish rejects on its own timeout, on a missing code/state and
// on an echoed-state mismatch, and the send can reject too. The approve
Expand All @@ -506,8 +510,7 @@ export class PairingAuthorityIntegration extends OAuthWebIntegration {
this.fail(err);
return;
}
}
else {
} else {
await firefox.pairAuthorize(this.channelId);
}

Expand All @@ -531,6 +534,25 @@ export class PairingAuthorityIntegration extends OAuthWebIntegration {
await firefox.pairComplete(this.channelId);
}

/**
* Ends the flow at the authority user's request.
*
* A channel the user closed and one that expired look identical from the
* other end, so the supplicant is told before the channel goes away —
* otherwise its dead-end screen blames a timeout for a pairing this user
* deliberately stopped.
*/
async cancel(): Promise<void> {
try {
await this._channel?.send('pair:auth:cancel', {});
} catch (err) {
// The notice is a courtesy to the other device. A channel that will not
// carry it still has to be torn down.
Sentry.captureException(err);
}
await this.destroy();
}

/** Clean up timers on unmount. */
async destroy() {
this.stopHeartbeat();
Expand All @@ -557,7 +579,7 @@ export class PairingAuthorityIntegration extends OAuthWebIntegration {
try {
await this._channel.close();
} catch (err) {
Sentry.captureException(err)
Sentry.captureException(err);
} finally {
this._channel = null;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,42 @@ describe('PairingSupplicantIntegration', () => {
});
});

describe('authority cancel', () => {
async function connectedIntegration() {
const integration = createIntegration();
await integration.openChannel('wss://ch.example.com', 'c', 'k');
emit('connected');
return integration;
}

it('ends the flow when the authority cancels', async () => {
const integration = await connectedIntegration();

emit('remote:pair:auth:cancel');

expect(integration.state).toBe(SupplicantState.Failed);
});

it('records that the authority cancelled', async () => {
const integration = await connectedIntegration();

emit('remote:pair:auth:cancel');

expect(integration.canceledByAuthority).toBe(true);
});

// Without a notice the close is indistinguishable from the channel running
// out of time, which is what the dead-end screen then has to say.
it('does not treat a bare channel close as a cancel', async () => {
const integration = await connectedIntegration();

emit('close');

expect(integration.state).toBe(SupplicantState.Failed);
expect(integration.canceledByAuthority).toBe(false);
});
});

describe('isPairing', () => {
it('returns true', () => {
expect(createIntegration().isPairing()).toBe(true);
Expand Down
Loading