Skip to content

Commit

Permalink
services/auth: helix API
Browse files Browse the repository at this point in the history
- update OAuth scopes
  - user:manage:blocked_users
  - user:read:blocked_users
  - user:read:follows
  - user:read:subscriptions
  - chat:edit
  - chat:read
  - whispers:edit
  - whispers:read
- validate OAuth access token via query-less twitch-user response
- add state parameter to OAuth URL and validate state response
- validate token_type OAuth response
- store scopes required by the application on the Auth record
  and reject logins if the stored scopes don't match the required ones
  instead of comparing scopes from an API response (unavailable now)
- update tests
  • Loading branch information
bastimeyer committed Jan 16, 2022
1 parent 0f5d5e0 commit 049cd63
Show file tree
Hide file tree
Showing 5 changed files with 180 additions and 117 deletions.
22 changes: 12 additions & 10 deletions src/app/data/models/auth/model.js
Original file line number Diff line number Diff line change
@@ -1,26 +1,28 @@
import { get, computed } from "@ember/object";
import { computed } from "@ember/object";
import attr from "ember-data/attr";
import Model from "ember-data/model";


// noinspection JSValidateTypes
export default Model.extend( /** @class Auth */ {
/** @type {string} */
access_token: attr( "string" ),
scope : attr( "string" ),
date : attr( "date" ),
/** @type {string} */
scope: attr( "string" ),
/** @type {Date} */
date: attr( "date" ),


// volatile property
// state properties
user_id: null,
user_name: null,

// status properties
isPending : false,
isPending: false,
isLoggedIn: computed( "access_token", "user_id", "isPending", function() {
let token = get( this, "access_token" );
let id = get( this, "user_id" );
let pending = get( this, "isPending" );
/** @this {Auth} */
const { access_token, user_id, isPending } = this;

return token && id && !pending;
return access_token && user_id && !isPending;
})

}).reopenClass({
Expand Down
89 changes: 57 additions & 32 deletions src/app/services/auth/service.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { set, setProperties, computed } from "@ember/object";
import { set, setProperties } from "@ember/object";
import Evented from "@ember/object/evented";
import { default as Service, inject as service } from "@ember/service";
import { twitch as twitchConfig } from "config";
import { randomBytes } from "crypto";
import HttpServer from "utils/node/http/HttpServer";
import OAuthResponseRedirect from "./oauth-redirect.html";

Expand All @@ -23,6 +24,11 @@ const {

const reToken = /^[a-z\d]{30}$/i;

// Twitch splits up scope names like this
const SEP_SCOPES_OAUTH = " ";
// This separator is used for backwards compatibility
const SEP_SCOPES_STORAGE = "+";


export default Service.extend( Evented, /** @class AuthService */ {
/** @type {NwjsService} */
Expand All @@ -35,15 +41,15 @@ export default Service.extend( Evented, /** @class AuthService */ {
/** @type {HttpServer} */
server: null,


url: computed(function() {
buildOAuthUrl( state ) {
const redirect = redirecturi.replace( "{server-port}", String( serverport ) );

return baseuri
.replace( "{client-id}", clientid )
.replace( "{redirect-uri}", encodeURIComponent( redirect ) )
.replace( "{scope}", expectedScopes.join( "+" ) );
}),
.replace( "{scope}", expectedScopes.join( SEP_SCOPES_OAUTH ) )
.replace( "{state}", state );
},


async autoLogin() {
Expand All @@ -62,7 +68,7 @@ export default Service.extend( Evented, /** @class AuthService */ {

// then perform auto-login afterwards
await this.login( access_token, true )
.catch( /* istanbul ignore next */ () => {} );
.catch( new Function() );
},

async _loadSession() {
Expand All @@ -89,6 +95,7 @@ export default Service.extend( Evented, /** @class AuthService */ {
if ( this.server ) {
throw new Error( "An OAuth response server is already running" );
}
const state = randomBytes( 16 ).toString( "hex" );

return new Promise( ( resolve, reject ) => {
const server = new HttpServer( serverport, 1000 );
Expand All @@ -100,10 +107,8 @@ export default Service.extend( Evented, /** @class AuthService */ {
});

server.onRequest( "GET", "/token", async ( req, res ) => {
const { access_token, scope } = req.url.query;

// validate token and scope and keep the request open
await this._validateOAuthResponse( access_token, scope )
await this._validateOAuthResponse( req.url.query, state )
.then( () => {
res.end( "OK" );
process.nextTick( resolve );
Expand All @@ -118,7 +123,8 @@ export default Service.extend( Evented, /** @class AuthService */ {

// open auth url in web browser
try {
this.nwjs.openBrowser( this.url );
const url = this.buildOAuthUrl( state );
this.nwjs.openBrowser( url );
} catch ( err ) {
reject( err );
}
Expand All @@ -139,17 +145,26 @@ export default Service.extend( Evented, /** @class AuthService */ {

/**
* Validate the OAuth response after a login attempt
* @param {string} token
* @param {string} scope
* @param {Object} query
* @param {string} query.token_type
* @param {string} query.access_token
* @param {string} query.scope
* @param {string} query.state
* @param {string} expectedState
* @returns {Promise}
*/
async _validateOAuthResponse( token, scope ) {
// check the returned token and validate scopes
if ( !reToken.test( token ) || !this._validateScope( String( scope ).split( " " ) ) ) {
async _validateOAuthResponse( query, expectedState ) {
const { token_type, access_token, scope, state } = query;
if (
token_type !== "bearer"
|| !reToken.test( access_token )
|| !this._validateScope( String( scope ).split( SEP_SCOPES_OAUTH ) )
|| state !== expectedState
) {
throw new Error( "OAuth token validation error" );
}

return await this.login( token, false );
return await this.login( access_token, false );
},

/**
Expand All @@ -164,6 +179,10 @@ export default Service.extend( Evented, /** @class AuthService */ {
set( session, "isPending", true );

try {
if ( isAutoLogin ) {
this._validateSessionScope();
}

// tell the twitch adapter to use the token from now on
this._updateAdapter( token );

Expand All @@ -172,11 +191,11 @@ export default Service.extend( Evented, /** @class AuthService */ {

if ( !isAutoLogin ) {
// save auth record if this was no auto login
await this._sessionSave( token, record );
await this._sessionSave( token );
}

// also don't forget to set the user_name on the auth record (regular properties)
const { user_id, user_name } = record;
const { id: user_id, login: user_name } = record;
setProperties( session, { user_id, user_name } );
success = true;

Expand All @@ -194,40 +213,46 @@ export default Service.extend( Evented, /** @class AuthService */ {

/**
* Adapter was updated. Now check if the access token is valid.
* @returns {Promise<TwitchRoot>}
* @returns {Promise<TwitchUser>}
*/
async _validateSession() {
/** @type {TwitchRoot} */
const twitchRoot = await this.store.queryRecord( "twitch-root", {} );
const { valid, user_name, scopes } = twitchRoot.toJSON();

if ( valid !== true || !user_name || !this._validateScope( scopes ) ) {
try {
// noinspection JSValidateTypes
return await this.store.queryRecord( "twitch-user", {} );
} catch ( e ) {
throw new Error( "Invalid session" );
}
},

return twitchRoot;
/**
* Check the scopes on the stored Auth record before attempting a login.
* Scopes can always be updated/extended in the future, requiring a new authentication.
*/
_validateSessionScope() {
const { scope } = this.session;
if ( !scope || !this._validateScope( scope.split( SEP_SCOPES_STORAGE ) ) ) {
throw new Error( "Invalid access token scopes" );
}
},

/**
* Received and expected scopes need to be identical
* @param {string[]} returnedScopes
* @param {string[]} scopes
* @returns {boolean}
*/
_validateScope( returnedScopes ) {
return Array.isArray( returnedScopes )
&& expectedScopes.every( item => returnedScopes.includes( item ) );
_validateScope( scopes ) {
return Array.isArray( scopes ) && expectedScopes.every( item => scopes.includes( item ) );
},


/**
* Update the auth record and save it
* @param {string} access_token
* @param {TwitchRoot} twitchRoot
* @returns {Promise}
*/
async _sessionSave( access_token, twitchRoot ) {
async _sessionSave( access_token ) {
const { session } = this;
const scope = twitchRoot.scopes.content.join( "+" );
const scope = expectedScopes.join( SEP_SCOPES_STORAGE );
const date = new Date();
setProperties( session, { access_token, scope, date } );

Expand Down
16 changes: 9 additions & 7 deletions src/config/twitch.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,18 @@
},
"oauth": {
"server-port": 65432,
"base-uri": "https://id.twitch.tv/oauth2/authorize?response_type=token&client_id={client-id}&redirect_uri={redirect-uri}&scope={scope}&force_verify=true",
"base-uri": "https://id.twitch.tv/oauth2/authorize?response_type=token&client_id={client-id}&redirect_uri={redirect-uri}&scope={scope}&force_verify=true&state={state}",
"client-id": "phiay4sq36lfv9zu7cbqwz2ndnesfd8",
"redirect-uri": "http://localhost:{server-port}/redirect",
"scope": [
"user_read",
"user_blocks_read",
"user_blocks_edit",
"user_follows_edit",
"user_subscriptions",
"chat_login"
"user:manage:blocked_users",
"user:read:blocked_users",
"user:read:follows",
"user:read:subscriptions",
"chat:edit",
"chat:read",
"whispers:edit",
"whispers:read"
]
}
}
9 changes: 9 additions & 0 deletions src/test/fixtures/services/auth/validate-session.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
request:
method: "GET"
url: "https://api.twitch.tv/helix/users"
query: {}
response:
data:
- id: "1337"
login: "user"
display_name: "User"
Loading

0 comments on commit 049cd63

Please sign in to comment.