-
Notifications
You must be signed in to change notification settings - Fork 15
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
Identity System Overhaul #167
base: next
Are you sure you want to change the base?
Changes from all commits
8a67824
9f9003c
9dcf5f9
69d1c22
d92d520
954a6af
41faeaf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
export interface UserData { | ||
//Subscribder Id | ||
sub: string, | ||
preferred_username: string, | ||
picture?: string, | ||
email?: string, | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,107 @@ | ||
/* ======= | ||
* NOTES | ||
* ======= | ||
* | ||
* Scope: | ||
* This implementation should be a pretty cut and dry OIDC | ||
* integration, very little should need to be changed to | ||
* interface with any other standard oidc provider. It was | ||
* developed against forgejo since it's the oidc provider | ||
* I had available locally. | ||
* | ||
* Documentation Referenced In Implementation | ||
* https://forgejo.org/docs/v1.19/user/oauth2-provider/ | ||
* https://forgejo.org/docs/v1.19/user/api-usage/ | ||
* | ||
* Vars | ||
* FORGEJO_API_KEY: Key for general api, needs access to read users | ||
* FORGEJO_CLIENT_SECRET: OIDC Client Secret, generated in /user/settings/applications | ||
* FORGEJO_CLIENT_ID: OIDC Client id, generated in /user/settings/applications | ||
* | ||
* FORGEJO_GETUSER_ENDPOINT: Forgejo Static API endpoint that uses general API key to get user info by id | ||
* | ||
* The following vars are all ripped from https://[YOUR-FORGEJO-URL]/.well-known/openid-configuration | ||
* This likely could be used to automatically grab the appropriate url, it simply isn't in this | ||
* implementation for convenience. | ||
* | ||
* FORGEJO_USERINFO_ENDPOINT | ||
* FORGEJO_AUTHORIZATION_ENDPOINT | ||
* FORGEJO_ACCESSTOKEN_ENDPOINT | ||
* | ||
*/ | ||
|
||
// ===================== | ||
// Imports and Globals | ||
// ===================== | ||
import axios, { AxiosResponse } from "axios"; | ||
import { UserData } from "../entity/identity"; | ||
|
||
const FORGEJO_API_KEY = process.env.FORGEJO_API_KEY as string | ||
const FORGEJO_CLIENT_SECRET = process.env.FORGEJO_CLIENT_SECRET as string | ||
const FORGEJO_CLIENT_ID = process.env.FORGEJO_CLIENT_ID as string | ||
|
||
const FORGEJO_USERINFO_ENDPOINT = process.env.FORGEJO_USERINFO_ENDPOINT as string | ||
const FORGEJO_AUTHORIZATION_ENDPOINT = process.env.FORGEJO_AUTHORIZATION_ENDPOINT as string | ||
const FORGEJO_GETUSER_ENDPOINT = process.env.FORGEJO_GETUSER_ENDPOINT as string | ||
const FORGEJO_ACCESSTOKEN_ENDPOINT = process.env.FORGEJO_ACCESSTOKEN_ENDPOINT as string | ||
|
||
const provider = "forgejo"; | ||
|
||
// ==================== | ||
// Exported Functions | ||
// ==================== | ||
|
||
const getUser = async (accessToken: string): Promise<UserData> => { | ||
return axios.get(FORGEJO_USERINFO_ENDPOINT, { | ||
headers: { | ||
'Authorization': `Bearer ${accessToken}` | ||
} | ||
}) | ||
.then((res) => res.data); | ||
} | ||
|
||
const getUsersById = async (userIds: string[]): Promise<UserData[] | null> => { | ||
const userDataPromises = userIds.map((userId) => | ||
axios.get(`${FORGEJO_GETUSER_ENDPOINT}?access_token=${FORGEJO_API_KEY}&uid=${userId}`) | ||
.then(resp => resp.data) | ||
.then((data: UserData) => data) | ||
); | ||
return Promise.all(userDataPromises); | ||
} | ||
|
||
const validateAuthServer = (): Promise<Boolean> => { | ||
return Promise.resolve(true); | ||
} | ||
|
||
const getRedirectURL = (redirectURL: string, scope: string, state: string): string => | ||
`${FORGEJO_AUTHORIZATION_ENDPOINT}?response_type=code&client_id=${FORGEJO_CLIENT_ID}&redirect_uri=${redirectURL}&scope=${scope}&state=${state}`; | ||
|
||
const handleRedirectResponse = async (code: string, state: string, redirectUri: string, grantType: string): Promise<UserData> => { | ||
const response = await axios.post(FORGEJO_ACCESSTOKEN_ENDPOINT, { | ||
client_id: FORGEJO_CLIENT_ID, | ||
client_secret: FORGEJO_CLIENT_SECRET, | ||
code: code, | ||
grant_type: grantType, | ||
redirect_uri: redirectUri, | ||
}, { | ||
headers: { | ||
'Content-Type': 'application/json', | ||
}, | ||
}); | ||
|
||
const { access_token } = response.data; | ||
|
||
const userData = await getUser(access_token) | ||
|
||
return userData; | ||
}; | ||
|
||
export { | ||
getUser, | ||
getUsersById, | ||
getRedirectURL, | ||
handleRedirectResponse, | ||
validateAuthServer, | ||
|
||
provider, | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
import { getUser, getUsersById, getRedirectURL, handleRedirectResponse, validateAuthServer, provider } from './twitch'; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. it looks like this does not currently allow for multiple identities to coexist? do we want that, or in that case, that just uses equivalent of forgejo? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. mydnzi was saying they preferred that we only allow a single provider to function at a time, so this is mostly a refactor of the existing single-provider system that's modular enough that I can swap out what I'm using for my local version of the instance There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a discussion (sorry about the tangents) in the thread about the goal of the PR. Stated goal was for supporting self-hosting with other providers, and I think it's preferable to serve that need but not make the rest of the code more complicated by supporting multiple concurrent identities from various providers. It's not a hard veto or anything, but I think there are a lot of knock-on effects we'd have to think about and deal with when someone can, say, have the same username on Google as a different human on Twitch and they both play in the same room for example. There are also consequences for banning and ban evading and so on There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (In other words, if the site runs with exactly and only one auth provider, but you can choose which auth provider to run it with, you support "people running different servers" while not complicating the actual lobby and game code) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I did, however, not do anything to intentionally prevent the future implementation of multiple Identity providers, just made it so it's easier to drop in a different set of handlers There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I believe you asked a question somewhere about how to generalize this so that it doesn't require a code change to switch providers. Here's one way you can do that.
Example: (current code) import * as twitch from './twitch';
import * as google from './google';
const providers = {twitch, google};
export const getIdentityProvider = (name: keyof typeof providers) => providers[name];
export const getConfiguredProvider = () => {
const name = process.env.IDENTITY_PROVIDER ?? 'twitch';
if (Object.prototype.hasOwnProperty.call(providers, name)) return getIdentityProvider(name as any);
throw new Error(`No such identity provider: '${name}'. Expected: ${Object.keys(providers).join(', ')}`);
}
If you want to ensure better conformance / compatibility using typescript, you can define the providers slightly differently to conform to an interface:
import { twitchProvider } from './twitch';
import { googleProvider } from './google';
const providers = {
twitch: twitchProvider as any as IdentityProvider<'twitch', UserData>,
google: googleProvider
} satisfies Record<string, IdentityProvider<string, UserData>>;
export const getIdentityProvider = <T extends keyof typeof providers>(name: T): typeof providers[T] => providers[name];
export const getConfiguredProvider = (): typeof providers[keyof typeof providers] => {
const name = process.env.IDENTITY_PROVIDER ?? 'twitch';
if (Object.prototype.hasOwnProperty.call(providers, name)) return getIdentityProvider(name as any);
throw new Error(`No such identity provider: '${name}'. Expected: ${Object.keys(providers).join(', ')}`);
}
export interface UserData {
//Subscribder Id
sub: string,
preferred_username: string,
picture?: string,
email?: string,
}
export interface IdentityProvider<Name extends string = string, T extends UserData = UserData> {
getUser(token: string): Promise<T>;
getUsersById(userIds: string[]): Promise<T[]>;
getRedirectURL(redirectURL: string, scope: string, state: string): string;
handleRedirectResponse(code: string, state: string, redirectUri: string, grantType: string): Promise<T>;
validateAuthServer(): Promise<Boolean>;
provider: Name;
}
import { type IdentityProvider, type UserData } from "./identity";
export const googleProvider: IdentityProvider<'google', UserData & {'googleSpecificStuff': 'etc'}> = {
getUser,
getUsersById,
getRedirectURL,
handleRedirectResponse,
validateAuthServer,
provider,
} Doing it this way will ensure that he functions exported by the identity provider conform to the defined interface in such a way that Typescript won't build if you've made an error. However, it also allows for specialization on top of that, as well as type discrimination: However, it also allows for specialization: if the Google provider |
||
|
||
export { | ||
getUser, | ||
getUsersById, | ||
getRedirectURL, | ||
handleRedirectResponse, | ||
provider, | ||
validateAuthServer, | ||
} | ||
|
||
|
||
/* | ||
import { UserData } from '../entity/identity'; | ||
|
||
Potential IdentityProvider Interface to make sure identity providers are handled consistently | ||
|
||
interface IdentityProvider { | ||
getUser(): Promise<UserData>; | ||
getUsersById(): Promise<UserData[]>; | ||
getRedirectURL(): string; | ||
handleRedirectResponse(): UserData; | ||
validateAuthServer(): Promise<Boolean>; | ||
|
||
provider: string; | ||
} | ||
*/ | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,172 @@ | ||
/* ======= | ||
* NOTES | ||
* ======= | ||
* | ||
* Scope: | ||
* Interfaces with twitch API to provide identity services and user lookup | ||
* | ||
* Documentation Referenced In Implementation | ||
* https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#implicit-grant-flow | ||
* | ||
* Vars | ||
* TWITCH_API_KEY: OAuth Secret and API key, accessed in twitch developer dashboard | ||
* TWITCH_CLIENT_ID: OAuth Client id, accessed in twitch developer dashboard | ||
* | ||
*/ | ||
|
||
// ===================== | ||
// Imports and Globals | ||
// ===================== | ||
import axios, { AxiosResponse } from "axios"; | ||
import { UserData } from "../entity/identity"; | ||
|
||
const TWITCH_API_KEY = process.env.TWITCH_API_KEY as string | ||
const TWITCH_CLIENT_ID = process.env.TWITCH_CLIENT_ID as string | ||
|
||
// ======================== | ||
// Interfaces and Helpers | ||
// ======================== | ||
|
||
interface TwitchAppAccessToken { | ||
access_token: string, | ||
expires_in: number, | ||
acquired_at?: number, | ||
token_type: string | ||
} | ||
|
||
interface TwitchGetUsersResponse { | ||
data: TwitchUserData[] | ||
} | ||
|
||
interface TwitchUserData { | ||
id: string, | ||
login: string, | ||
display_name: string, | ||
type: string, | ||
broadcaster_type: string, | ||
description: string, | ||
profile_image_url: string, | ||
offline_image_url: string, | ||
view_count: number, | ||
email: string, | ||
created_at: string | ||
} | ||
|
||
const toUserData = (twitchUserData: TwitchUserData): UserData => ({ | ||
sub: twitchUserData.id, | ||
preferred_username: twitchUserData.display_name, | ||
picture: twitchUserData.profile_image_url, | ||
email: twitchUserData.email, | ||
}); | ||
|
||
const getServerAccessToken = async (): Promise<TwitchAppAccessToken | null> => { | ||
if (serverAccessToken) { | ||
let expiration = serverAccessToken.acquired_at!! + serverAccessToken.expires_in * 1000 | ||
if (Date.now() - expiration <= 120000) { //if the expiration is within 2 minutes, lets refresh the token | ||
console.log('Tokens expired!') | ||
serverAccessToken = undefined | ||
} | ||
else return serverAccessToken | ||
} | ||
|
||
if (!serverAccessToken) { | ||
console.log('Fetching access token for Twitch API...') | ||
const access: TwitchAppAccessToken | null = await axios.post('https://id.twitch.tv/oauth2/token', null, { | ||
params: { | ||
client_id: TWITCH_CLIENT_ID, | ||
client_secret: TWITCH_API_KEY, | ||
grant_type: 'client_credentials', | ||
}, | ||
}) | ||
.then((res: AxiosResponse<TwitchAppAccessToken>) => res.data) | ||
.catch((e) => { | ||
console.log(e) | ||
return null | ||
}) | ||
console.log('Fetched?') | ||
if (access) { | ||
access.acquired_at = Date.now() | ||
serverAccessToken = access | ||
console.log('Yes!') | ||
} | ||
else { | ||
console.log('Nope!') | ||
} | ||
} | ||
return serverAccessToken!! | ||
} | ||
|
||
// ==================== | ||
// Exported Functions | ||
// ==================== | ||
|
||
const getUser = async (accessToken: string): Promise<UserData> => { | ||
return axios.get("https://api.twitch.tv/helix/users", { | ||
headers: { | ||
'Authorization': `Bearer ${accessToken}`, | ||
'Client-Id': TWITCH_CLIENT_ID | ||
} | ||
}) | ||
.then((res: AxiosResponse<TwitchGetUsersResponse>) => res.data) | ||
.then(data => data.data[0]) | ||
.then(toUserData); | ||
} | ||
|
||
const getUsersById = async (userIds: string[]): Promise<UserData[] | null> => { | ||
let tokens = await getServerAccessToken() | ||
if (!tokens) throw new Error("getUsersById: Unable to communicate with Twitch!") | ||
const query = userIds.map((userId) => `id=${userId}`).join('&') | ||
return axios.get(`https://api.twitch.tv/helix/users?${query}`, { | ||
headers: { | ||
'Authorization': `Bearer ${tokens.access_token}`, | ||
'Client-Id': TWITCH_CLIENT_ID | ||
} | ||
}) | ||
.then((res: AxiosResponse<TwitchGetUsersResponse>) => res.data) | ||
.then(data => data.data) | ||
.then((twitchUsers: TwitchUserData[]) => twitchUsers.map(toUserData)) | ||
.catch((e) => { | ||
console.log(e) | ||
return null | ||
}) | ||
} | ||
|
||
const validateAuthServer = async (): Promise<Boolean> => { | ||
const token = getServerAccessToken(); | ||
return !!token; | ||
} | ||
|
||
const getRedirectURL = (redirectURL: string, scope: string, state: string): string => | ||
`https://id.twitch.tv/oauth2/authorize?response_type=code&client_id=${TWITCH_CLIENT_ID}&redirect_uri=${redirectURL}&scope=${scope}&state=${state}`; | ||
|
||
const handleRedirectResponse = async (code: string, state: string, redirectUri: string, grantType: string): Promise<UserData> => { | ||
const response = await axios.post('https://id.twitch.tv/oauth2/token', null, { | ||
params: { | ||
client_id: TWITCH_CLIENT_ID, | ||
client_secret: TWITCH_API_KEY, | ||
code: code, | ||
grant_type: grantType, | ||
redirect_uri: redirectUri, | ||
}, | ||
}); | ||
|
||
const { access_token } = response.data; | ||
|
||
const userData = await getUser(access_token) | ||
|
||
return userData; | ||
}; | ||
|
||
let serverAccessToken: TwitchAppAccessToken | undefined = undefined | ||
|
||
const provider = "twitch"; | ||
|
||
export { | ||
getUser, | ||
getUsersById, | ||
getRedirectURL, | ||
handleRedirectResponse, | ||
validateAuthServer, | ||
|
||
provider, | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This directory (entity) is for TypeORM models; this interface is probably better-kept in the
identity
directory alongside the oauth stuff that depends on itThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Existing response type definitions for the pure twitch implementation were in the entity directory, I can find a new place to move it to, but I can't put it in the
identity.ts
since it would cause a cyclical dependency.