Skip to content

Connecting Twitch OpenID to Firebase auth

Created: 2020-04-04 09:28:27 -0700 Modified: 2020-07-29 09:11:43 -0700

This process took me three solid days to figure out even with the help of the official Instagram example, hence why this gets its own note. I’ll detail almost everything that I explored here.

The goals were:

  • Add a “Register/login with Twitch” button to my site
  • Allow connecting Twitch as another identity provider to an existing account (e.g. a user registers with Google, adds Twitch to the account, then can log in with either)
  • Treat Twitch as similarly as possible to the officially supported identity providers

Below the steps/code are more notes and conclusions from this.

Getting the client_id and client_secret to your Cloud Function

Section titled Getting the client_id and client_secret to your Cloud Function
  • Make a Twitch app
    • Log in to dev.twitch.tv
    • Go to your dashboard
    • Click “Register your application” (direct link here)
    • FYI: the name will show up for users, but you can always change this later.
    • For the OAuth Redirect URL, I set mine to http://localhost:3000/popup at first. Note: I’m not actually doing things via a popup in this example, but I still named it that.
  • Get your Twitch app’s client ID and secret
    • After making the app, you’ll be on this page, so click “Manage”
    • Copy your client ID and secret out of this page. The secret must be generated every time you want to see it, so save it somewhere safe unless you don’t mind regenerating it later.
  • Let Firebase know about the ID/secret (reference)
    • I was testing this in the emulator, so I just put it into .runtimeconfig.json alongside my functions:
{
"twitch": {
"client_id": "paste from before",
"client_secret": "paste from before"
}
}
  • Note: we need the client_secret because we’re using the OAuth authorization flow, not the implicit code flow, and Twitch says to use the authorization code flow when “Your application uses a server, can securely store a client secret, and can make server-to-server requests.” (reference)

In the code below, I don’t include basic boilerplate code like “require” statements, Firebase setup, or proper function exporting. I also only included the parts I wanted to talk about.

Code:

const OAUTH_REDIRECT_URI = `http://localhost:3000/popup`;
const OAUTH_SCOPES = 'user:read:email openid'; // space-separated
/**
* Creates a configured simple-oauth2 client for Twitch.
*/
function twitchOAuth2Client() {
// Twitch OAuth 2 setup
const credentials = {
client: {
id: functions.config().twitch.client_id,
secret: functions.config().twitch.client_secret,
},
auth: {
tokenHost: 'https://id.twitch.tv',
revokePath: '/oauth2/revoke',
tokenPath: '/oauth2/token',
authorizePath: '/oauth2/authorize',
},
};
return require('simple-oauth2').create(credentials);
}

Notes:

  • Apparently Twitch uses non-default OAuth paths. The simple-oauth2 library’s create() function says that the defaults are /oauth/foo, not /oauth2/foo, so we have to override them.
  • Here’s a list of all supported scopes. The documentation lists “openid” as being in the V5 API (which is already deprecated even as I write these notes on 4/4/20 (reference)), but it’s needed in order to fetch some data right at the time of getting a token. By doing this, we save 1-2 API calls.

Code:

/**
* Sets up a redirect URL for the user. This also sets a "state" cookie so that
* we can protect against CSRF attacks. This generated state has to be tied to
* the user, and a cookie is the only real way to do that considering we don't
* necessarily have any other identifiers for this client, e.g. if they haven't
* signed up yet or if their only identity is Twitch.
*
* If the cookies are ever a problem, then we'll need a temporary identity for
* the client so that we can store an ID → state mapping in Firestore.
*/
exports.redirect = functions.https.onRequest((req, res) => {
const oauth2 = twitchOAuth2Client();
cookieParser()(req, res, () => {
// Generate a random state to prevent against CSRF attacks.
const state = crypto.randomBytes(20).toString('hex');
// For production, we need secure:true and sameSite:'None' or else we get
// this error in Chrome:
//
// A cookie associated with a cross-site resource at
// http://us-central1-your-project.cloudfunctions.net/ was set without the
// `SameSite` attribute. It has been blocked, as Chrome now only delivers
// cookies with cross-site requests if they are set with `SameSite=None` and
// `Secure`. You can review cookies in developer tools under
// Application>Storage>Cookies and see more details at
// https://www.chromestatus.com/feature/5088147346030592 and
// https://www.chromestatus.com/feature/5633521622188032.
//
// Thus, we pull in the "secure" and "sameSite" properties from the config.
const secureCookieConfigValue = functions.config().oauth_state_cookie
.secure;
const useSecureCookies =
secureCookieConfigValue === true || secureCookieConfigValue === 'true';
// Docs for res.cookie here: https://expressjs.com/en/api.html#res.cookie
res.cookie('state', state.toString(), {
maxAge: 60 * 60 * 1000, // this is in milliseconds
secure: useSecureCookies,
sameSite: functions.config().oauth_state_cookie.same_site,
httpOnly: true,
});
const redirectUri = oauth2.authorizationCode.authorizeURL({
redirect_uri: OAUTH_REDIRECT_URI,
scope: OAUTH_SCOPES,
state,
// Docs for claims here: https://dev.twitch.tv/docs/authentication/getting-tokens-oidc/#oidc-authorization-code-flow
claims: JSON.stringify({
id_token: {
email: null,
email_verified: null,
picture: null,
preferred_username: null,
},
}),
});
return res.redirect(redirectUri);
});
});

Notes:

  • This function represents the start of the client flow:
  • Docs for authorizeURL are below:
  • We specify “claims” because we want the user’s email address (among other things) without having to call extra APIs later. Remember: you need the “openid” scope to be able to fetch these claims without extra calls.
  • As mentioned in the code’s comments, the cookie settings differ between localhost and production. Cookies were a gigantic concern for me because when they weren’t working for me locally, I found this well-detailed SO post. At the time, I didn’t know that the reason they weren’t working for me was locally was because of the “sameSite” and “secure” properties, so I thought I would have problems in production since the site would be served from adamlearns.live, but the Cloud Functions would run from https://us-central1-adamlearns.cloudfunctions.net/foo
    • The SO post mentions “As of August 2019, Firefox and Safari block 3rd-party cookies by default. Most (if not all) ad blockers and similar extensions also block them.”, but this isn’t completely true. It’s April of 2020 and Chrome doesn’t actually have “Block third-party cookies” on by default apparently (Settings → Privacy → Cookies and site data). However, Brave and Safari do block third-party cookies. To work around this, I did the following:
      • Use hosting rewrites to point “/function/twitchRedirect” at the function itself
      • Make my client point at example.com/function/twitchRedirect instead of projectId.firebaseapp.com/twitchRedirect
      • Deployed my functions
      • Deployed my hosting
  • The “state” variable is part of OAuth. If an identity provider doesn’t support PKCE, then “state” is required to prevent CSRF attacks (see IETF OAuth Best Practices section 3.1). The problem is that at this point in the code flow, you don’t necessarily have a way to identify the user (e.g. because they’re registering and don’t already have an identity with the system). Because of that, cookies seemed like the only reasonable storage for “state”, and it’s what Firebase does in their Instagram example code.
    • I think this site does the best job of explaining the attack itself. Keep in mind that “client” is essentially the Cloud Function in this case, not the end user. Here’s my understanding in the context of The AcAdamy (so that we can avoid technical terms):
  1. The attacker makes a Twitter account.

  2. The attacker goes to The AcAdamy and starts the “log in with Twitter” flow but intentionally doesn’t finish it. They get to the point where they have code (and would ideally have state if it were being used properly, but let’s omit it and say that it was developed poorly).

  3. The attacker gets the victim to go to some totally unrelated page that they control (or one that’s vulnerable to an XSS attack), e.g. evilhacks.com. On this page, they put HTML like \<img src="https://adamlearns.live/login?code=THE_ATTACKERS_CODE"/\>.

  4. The victim’s browser automatically contacts https://adamlearns.live and performs a login request but linked to the attacker’s account.

I believe this also requires having a “GET” request that does something sensitive, e.g. logging in or linking an account.

  • Like this stackexchange post talks about, you need some way of identifying the user with private data that they provide. Without using cookies, the only thing I could really think of here was something like an anonymous Firebase account just to generate a UID that we could key off of. The user would make the account, send their token, the server would validate the token and then any number of things could happen, e.g. making a UUID and storing it in Firestore for the user to be able to fetch.
    • passport-oauth2’s code stores this into req.session (reference). Session storage can be Redis, Firestore (reference, reference2), etc., but I didn’t look too far into how they identify a user. It’s probably a cookie.
  • I had thought about having the server generate a UUID, signing it into a token, and sending that to the client, but I think that would be susceptible to a CSRF attack still.

Code:

/**
* This code-path is used to get a token from Twitch. However, it doesn't
* necessarily return that token to the user. The semantics depend on what the
* user wants to do:
*
* 1. If the user is just linking Twitch to an existing account, then we can set
* that up entirely here on the back-end without needing to return anything
* other than a success code.
* 2. If the user is logging in or registering, then we'll return a Firebase
* token (NOT their Twitch token) that they can use to log in to the site.
*/
async function getToken(req, res) {
const oauth2 = twitchOAuth2Client();
try {
const {
body: { state: bodyState, code, currentIdToken, intent },
cookies: { state: cookieState },
} = req;
if (!cookieState) {
throw new Error('cookieState unset or expired');
} else if (cookieState !== bodyState) {
throw new Error('State validation failed');
}
const results = await oauth2.authorizationCode.getToken({
code,
client_id: functions.config().twitch.client_id,
scope: OAUTH_SCOPES,
client_secret: functions.config().twitch.client_secret,
redirect_uri: OAUTH_REDIRECT_URI,
});
const { id_token: twitchJwt } = results;
// This includes access_token and refresh_token, which we don't use.
const decodedJwt = await verifyTwitchJwt(twitchJwt);
const {
sub: twitchId,
preferred_username: userName,
picture: profilePic,
email,
email_verified: emailVerified,
} = decodedJwt;
if (_.isNil(email) || !emailVerified) {
throw new Error(
`User did not verify their Twitch email address: twitchId=${twitchId}`
);
}
// The UID we'll assign to the user. Prefix it so that there are no
// collisions.
const twitchUid = `twitch:${twitchId}`;
if (intent === 'link') {
await linkUnofficialIdentityProvider(currentIdToken, twitchUid);
return res.status(200).send();
} else {
const token = await createFirebaseAccountIfNecessary(
twitchUid,
userName,
profilePic,
email
);
return res.status(200).send({ token });
}
} catch (error) {
console.error('Error in getToken', error);
return res.status(400).send();
}
}

Notes:

  • We ensure that the “state” hasn’t changed since we assigned it. The cookie was set as httpOnly, meaning JavaScript couldn’t be used to read or modify the cookie.
    • Also, keep in mind that while res.cookie does allow you to set a domain, a particular site can set cookies for itself, child domains, and its parent, but not sibling subdomains (reference).
  • We can get the user’s email, email_verified, etc. because of the “openid” scope and the claims we passed earlier.
  • We get an accesstoken and refresh_token, but I don’t even save them since I don’t really have a use for them yet, and I didn’t look into best practices on how best to store them. I imagine that sites typically just put them as-is into a database so that they can act as the user here, and it’s up to the user to revoke the credentials if something goes haywire. This is how a site like, say, Twitch, lets me export videos to YouTube for _years without having to update my credentials.
  • I take in some extra properties for the sake of linking the Twitch account to the user’s existing account: “intent” and “currentIdToken”. “intent” is just an indication that they want to link in the first place, in which case “currentIdToken” will be decoded (“admin.auth().verifyIdToken(currentIdToken)”) and used as a look-up for the user. By doing things this way, I don’t have to do another request to the server if I already knew the purpose of getting a token was to link accounts.

Associating the unofficial identity provider with an official one

Section titled Associating the unofficial identity provider with an official one

Code:

/**
* Looks up a user by their UID, accounting for unofficial Firebase identity
* providers. For example, Twitter is an official identity provider, so
* getUser() will work directly on such a UID even if it wasn't the identity
* provider that the user originally signed up with. However, something like
* Twitch isn't officially supported, so if the user attached it to a Twitter
* account, then we have to find the Twitch UID in Firestore rather than in
* Firebase Authentication.
*
* This will NOT throw an error if the user isn't found. Instead, it returns a
* null userRecord.
* @param {string} uid
* @return {Object}
*/
const findUserRecordByUid = async function (uid) {
let userRecord = null;
let usersDocument = null;
if (uid.startsWith("twitch:")) {
// Attempt to look them up in Firestore since "twitch:" is never allowed as
// the "main" UID on the account, otherwise you wouldn't be able to
// disconnect Twitch from such an account and later reconnect it.
const querySnapshot = await usersRef
.where("unofficialProviderIds", "array-contains", uid)
.get();
if (!querySnapshot.empty) {
const usersDocument = _.head(querySnapshot.docs);
const { uid } = usersDocument.data();
userRecord = await admin.auth().getUser(uid);
}
} else {
try {
userRecord = await admin.auth().getUser(uid);
} catch (error) {
if (error.code !== "auth/user-not-found") {
throw error;
}
}
}
// One or both of these can be null.
return {
userRecord,
// This is returned to save us a read in Firestore later
usersDocument,
};
};

Notes:

  • The code is straightforward enough, but it’s the design that’s important. First of all, here’s some background/explanation on the code:

    • As hinted by this code (although not explicitly shown in this note), the user’s Twitch ID will get prefixed with “twitch:” before being stored, so if their ID is 12345 on Twitch, the UID we store from getToken is “twitch:12345”. This is important because it means we’ll never collide with another platform, including Firebase’s randomly generated UIDs (which don’t include colons in them).
    • Every user gets their own document in the “users” collection. This document has a “uid” string, an “unofficialProviderIds” array, and any other app-specific fields you want to include.
  • Let’s talk about design now. One of the goals of this endeavor was to be able to treat Twitch like it’s an official identity provider even though it isn’t. If we were to use “twitch:12345” as a user’s official UID, we would run into a problem with this flow:

    • User registers with Twitch. Their UID is “twitch:12345”.

    • User links Twitter to their account.

    • User disconnects Twitch. At this point, their UID will still be “twitch:12345”. It’s not possible to easily change a user’s UID (reference), and even if it were, you’d need to update everything in Firestore where you’re referring to that UID.

    • User wants to sign up as a separate account with Twitch. They can’t, because their UID is in use.

    • This flow doesn’t really represent a real scenario for most users, but it demonstrates a problem with using an unofficial provider’s IDs as UIDs in Firebase. Regardless, it’s strange to have something like “twitch:12345” used to represent a user who no longer has Twitch attached.

    • When creating a user, we let Firebase come up with a random UID for us. When we create their document in the “users” collection, we start “unofficialProviderIds” with “twitch:12345”. Then, this code will be able to look them up.

    • Later, when we want to disconnect an unofficial identity, we would simply remove it from the unofficialProviderIds array in the database.

  • This GitHub comment suggested storing unofficial IDs via setCustomUserClaims, but I decided against that since it’s apparently typically used for role-based authorization, and it’s limited to 1000 bytes.

Section titled Code for fetching Twitch data that is already returned via the claims-related code

This code is pointless; I’m only including it because it was nice to have before I knew about returning all of this via the claims stuff earlier:

// https://dev.twitch.tv/docs/authentication/getting-tokens-oidc#userinfo-endpoint
async function fetchTwitchUserId(accessToken) {
try {
const res = await fetch("https://id.twitch.tv/oauth2/userinfo", {
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
const json = await res.json();
console.log("json: " + JSON.stringify(json));
return json.sub;
} catch (error) {
console.error("Error asking Twitch for user information:", error);
throw new functions.https.HttpsError("unknown");
}
}
async function fetchTwitchUserInfo(accessToken, twitchId) {
try {
const queryParams = {
id: twitchId,
};
const baseUrl = "https://api.twitch.tv/helix/users";
const fullUrl = baseUrl + "?" + querystring.stringify(queryParams);
const res = await fetch(fullUrl, {
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
const json = await res.json();
console.log("json: " + JSON.stringify(json));
const { data: dataArray } = json;
console.log("dataArray: " + JSON.stringify(dataArray));
if (_.isNil(dataArray) || _.isEmpty(dataArray)) {
throw new Error(`Could not find "data" for twitchId=${twitchId}`);
}
if (_.size(dataArray) > 1) {
throw new Error(
`Somehow returned multiple users for twitchId=${twitchId}`
);
}
const userData = _.head(dataArray);
if (_.isNil(userData.email)) {
throw new Error(
`User did not verify their Twitch email address: twitchId=${twitchId}`
);
}
return userData;
} catch (error) {
console.error("Error asking Twitch for user information:", error);
throw new functions.https.HttpsError("unknown");
}
}
  • I would’ve loved to have tried the Instagram example code, but…
    • It doesn’t work anymore? (reference)
    • Instagram has some limitations around even making an app. I think I applied for an app ~2-3 days ago, and for some reason I still see a “Registration Disabled” button (side note: I don’t know why it’s even a button):

UPDATE (4/7/20) apparently this is due to COVID-19; Facebook sent content reviewers home (reference)

  • Twitch allows you to specify a nonce even for the OAuth authorization flow (reference), and this nonce is separate from the “state” parameter. To be honest, this didn’t make much sense to me since it says it’s supposed to prevent CSRF attacks, but the client in this case is a Cloud Function (i.e. not a browser, so not susceptible to CSRF attacks). Even Auth0 only talks about a nonce parameter for the implicit code flow (reference), so I’m led to believe that Twitch’s documentation just isn’t great.
  • I parameterized more than I showed here in the code examples. For example, the redirect URL, CORS policies, etc. are all in the Cloud Functions config (i.e. “firebase functions:config:get ”). By doing this, I could easily make environments for testing on localhost or on the cloud.
  • Authentication cannot be emulated yet, so when testing with an emulator, your state may not match the cloud’s state (e.g. your local Firestore may have records for users that no longer exist or no records for users that should exist).
  • I learned about creating and signing in with custom tokens (reference). This was problematic originally because I didn’t realize that signing in with the custom token would automatically put me into Firebase authentication (although that makes sense), which meant I would end up squatting on my own email for accounts that weren’t fully set up yet.
  • The biggest question I still have after all of this is: what does the “state” property of OAuth actually prevent against? Everyone says CSRF attacks, but what is the full attack, and what is the attacker actually gaining?