Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(auth): accept equivalencies when comparing scopes #343

Merged
merged 5 commits into from
Mar 29, 2022
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 4 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
module.exports = {
preset: 'ts-jest',
verbose: true
};
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
"@d-fischer/documen.ts": "^0.14.1",
"@d-fischer/eslint-config": "^5.0.0",
"@types/node": "^12.12.47",
"@types/jest": "^27.4.1",
"jest": "^27.5.1",
"jest-environment-node": "^27.5.1",
"ts-jest": "^27.1.4",
"cross-env": "^7.0.3",
"eslint": "^7.17.0",
"eslint-import-resolver-lerna": "^2.0.0",
Expand All @@ -29,7 +33,8 @@
"build": "lerna run build",
"rebuild": "lerna run rebuild",
"docs": "documen.ts",
"lerna": "lerna"
"lerna": "lerna",
"test": "jest"
},
"husky": {
"hooks": {
Expand Down
2 changes: 1 addition & 1 deletion packages/api/src/api/helix/channel/HelixChannelApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export class HelixChannelApi extends BaseApi {
type: 'helix',
url: 'channels',
method: 'PATCH',
scope: 'user:edit:broadcast',
scope: 'channel:manage:broadcast',
query: {
broadcaster_id: userId
},
Expand Down
4 changes: 2 additions & 2 deletions packages/api/src/api/helix/stream/HelixStreamApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ export class HelixStreamApi extends BaseApi {
url: 'streams/markers',
method: 'POST',
type: 'helix',
scope: 'user:edit:broadcast',
scope: 'channel:manage:broadcast',
query: {
user_id: extractUserId(broadcaster),
description
Expand Down Expand Up @@ -265,7 +265,7 @@ export class HelixStreamApi extends BaseApi {
await this._client.callApi({
type: 'helix',
url: 'streams/tags',
scope: 'user:edit:broadcast',
scope: 'channel:manage:broadcast',
method: 'PUT',
query: {
broadcaster_id: extractUserId(broadcaster)
Expand Down
66 changes: 66 additions & 0 deletions packages/auth/src/__tests__/helpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { compareScopes } from '../helpers';

describe('Scope comparer', () => {
it('does nothing when required scopes are absent', () => {
compareScopes([]);
compareScopes([], []);
compareScopes(['channel:moderate']);
compareScopes(['channel:moderate'], []);
});

it('passes when required scopes are met', () => {
compareScopes(['bits:read'], ['bits:read']);
compareScopes(['bits:read', 'channel:moderate'], ['bits:read']);
compareScopes(['bits:read', 'channel:moderate'], ['bits:read', 'channel:moderate']);
compareScopes(['bits:read', 'channel:moderate', 'channel:read:goals'], ['bits:read', 'channel:moderate']);
compareScopes(
['bits:read', 'channel:moderate', 'channel:read:goals'],
['bits:read', 'channel:moderate', 'channel:read:goals']
);
});

function expectError(scopesToCompare: string[], requestedScopes?: string[]) {
expect(() => {
compareScopes(scopesToCompare, requestedScopes);
}).toThrow();
}

it('throws error when a require scope is not present', () => {
expectError([], ['bits:read']);
expectError(['channel:moderate'], ['bits:read']);
expectError(['channel:moderate'], ['bits:read', 'channel:moderate']);
expectError(['channel:moderate'], ['channel:moderate', 'bits:read']);
expectError(['channel:moderate', 'channel:read:goals'], ['channel:moderate', 'bits:read']);
expectError(
['channel:moderate', 'channel:read:goals'],
['channel:moderate', 'channel:read:goals', 'bits:read']
);
});

it('passes for scope equivalencies', () => {
compareScopes(['user:edit:broadcast'], ['channel:manage:broadcast']);
compareScopes(['channel_subscriptions'], ['channel:read:subscriptions']);
compareScopes(['channel_subscriptions', 'channel:read:subscriptions'], ['channel:read:subscriptions']);
compareScopes(
['channel_subscriptions', 'channel:read:subscriptions'],
['channel_subscriptions', 'channel:read:subscriptions']
);
compareScopes(['channel_subscriptions', 'user_blocks_read'], ['channel:read:subscriptions']);
compareScopes(
['channel_subscriptions', 'user_blocks_read'],
['channel:read:subscriptions', 'user:read:blocked_users']
);
compareScopes(
['channel_subscriptions', 'user_blocks_read', 'channel:read:goals'],
['channel:read:subscriptions', 'user:read:blocked_users']
);
compareScopes(
['channel_subscriptions', 'user_blocks_read', 'channel:read:goals'],
['channel:read:subscriptions', 'user:read:blocked_users', 'channel:read:goals']
);
});

it('avoids undesired reverse scope equivalencies', () => {
expectError(['channel:manage:broadcast'], ['user:edit:broadcast']);
});
});
33 changes: 29 additions & 4 deletions packages/auth/src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,20 +190,45 @@ export async function getValidTokenFromProvider(
throw lastTokenError ?? new Error('Could not retrieve a valid token');
}

const scopeEquivalencies = new Map([
['channel_commercial', 'channel:edit:commercial'],
['channel_editor', 'channel:manage:broadcast'],
['channel_read', 'channel:read:stream_key'],
['channel_subscriptions', 'channel:read:subscriptions'],
['user_blocks_read', 'user:read:blocked_users'],
['user_blocks_edit', 'user:manage:blocked_users'],
['user_follows_edit', 'user:edit:follows'],
['user_read', 'user:read:email'],
['user_subscriptions', 'user:read:subscriptions'],
['user:edit:broadcast', 'channel:manage:broadcast']
]);

/**
* Compares scopes for a non-upgradable `AuthProvider` instance.
*
* @param scopesToCompare The scopes to compare against.
* @param requestedScopes The scopes you requested.
*/
export function compareScopes(scopesToCompare: string[], requestedScopes?: string[]): void {
d-fischer marked this conversation as resolved.
Show resolved Hide resolved
if (requestedScopes?.some(scope => !scopesToCompare.includes(scope))) {
throw new Error(
`This token does not have the requested scopes (${requestedScopes.join(', ')}) and can not be upgraded.
if (requestedScopes !== undefined) {
const scopes = new Set<string>();
for (const scope of scopesToCompare) {
scopes.add(scope);

const equivalent = scopeEquivalencies.get(scope);
if (equivalent !== undefined) {
scopes.add(equivalent);
}
}

if (requestedScopes.some(scope => !scopes.has(scope))) {
throw new Error(
`This token does not have the requested scopes (${requestedScopes.join(', ')}) and can not be upgraded.
If you need dynamically upgrading scopes, please implement the AuthProvider interface accordingly:

\thttps://twurple.js.org/reference/auth/interfaces/AuthProvider.html`
);
);
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion packages/pubsub/src/SingleUserPubSubClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ export class SingleUserPubSubClient {
* It receives a {@PubSubSubscriptionMessage} object.
*/
async onSubscription(callback: (message: PubSubSubscriptionMessage) => void): Promise<PubSubListener<never>> {
return await this._addListener('channel-subscribe-events-v1', callback, 'channel_subscriptions');
return await this._addListener('channel-subscribe-events-v1', callback, 'channel:read:subscriptions');
}

/**
Expand Down
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,5 @@
"isolatedModules": true,
"declarationMap": true
},
"exclude": ["node_modules", "lib", "es"]
"exclude": ["node_modules", "lib", "es", "__tests__"]
}