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
3 changes: 3 additions & 0 deletions config/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,9 @@
"eventLogGroupId": "",
"enabled": false,
"botToken": "",
"clientId": "",
"clientSecret": "",
"redirectUri": "http://localhost:8080/auth/telegram/callback",
"groups": [],
"trialPeriod": {
"start": {
Expand Down
11 changes: 11 additions & 0 deletions config/local.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,17 @@
"blockedGuilds": [],
"allowedUsers": [],
"clientPrompt": "none"
},
{
"enabled": false,
"type": "telegram",
"name": "telegram",
"botToken": "",
"clientId": "",
"clientSecret": "",
"redirectUri": "http://localhost:8080/auth/telegram/callback",
"groups": [],
"allowedUsers": []
}
],
"areaRestrictions": [
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@
"i18next-browser-languagedetector": "8.0.0",
"i18next-fs-backend": "2.6.6",
"i18next-http-backend": "3.0.5",
"jose": "^5.9.6",
"knex": "3.1.0",
"leaflet": "1.9.4",
"leaflet-arrowheads": "^1.4.0",
Expand All @@ -166,6 +167,7 @@
"passport": "^0.6.0",
"passport-discord": "https://github.com/tonestrike/passport-discord.git",
"passport-local": "^1.0.0",
"passport-oauth2": "^1.8.0",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-ga4": "^1.4.1",
Expand Down
2 changes: 1 addition & 1 deletion packages/config/.configref
Original file line number Diff line number Diff line change
@@ -1 +1 @@
26052
26175
1 change: 1 addition & 0 deletions packages/locales/lib/human/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,7 @@
"go_back": "Go Back",
"access": "Access",
"link_discord": "Link Discord",
"link_telegram": "Link Telegram",
"select_webhook_strategy": "Alert Manager",
"webhook_strategy_success_0": "Success! Refreshing to fetch alert settings...",
"register": "Register",
Expand Down
8 changes: 8 additions & 0 deletions packages/types/lib/augmentations.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ declare module '@mui/material/styles' {
fuchsia: string
red: string
}
telegram: {
main: string
contrastText: string
}
}

interface PaletteOptions {
Expand All @@ -61,6 +65,10 @@ declare module '@mui/material/styles' {
fuchsia: string
red: string
}
telegram?: {
main: string
contrastText: string
}
}
}

Expand Down
2 changes: 2 additions & 0 deletions packages/types/lib/blocks.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ interface CustomTelegram extends BaseBlock {
type: 'telegram'
telegramBotName: string
telegramAuthUrl: string
/** Resolved server side from `telegramAuthUrl`, not set in config */
telegramOAuth?: boolean
}

interface CustomLocal extends BaseBlock {
Expand Down
15 changes: 9 additions & 6 deletions server/src/graphql/resolvers.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const { missing, readAndParseJson } = require('@rm/locales')

const { buildDefaultFilters } = require('../filters/builder/base')
const { filterComponents } = require('../utils/filterComponents')
const { annotateTelegramBlocks } = require('../utils/getTelegramStrategy')
const { validateSelectedWebhook } = require('../utils/validateSelectedWebhook')
const { PoracleAPI } = require('../services/Poracle')
const { geocoder } = require('../services/geocoder')
Expand Down Expand Up @@ -146,14 +147,16 @@ const resolvers = {
components = [],
...rest
} = config.getMapConfig(req)[component]
const strategies = config.getSafe('authentication.strategies')
const prepare = (blocks) =>
annotateTelegramBlocks(
filterComponents(blocks, !!username, perms.donor),
strategies,
)
return {
...rest,
footerButtons: filterComponents(
footerButtons,
!!username,
perms.donor,
),
components: filterComponents(components, !!username, perms.donor),
footerButtons: prepare(footerButtons),
components: prepare(components),
}
}
return null
Expand Down
136 changes: 133 additions & 3 deletions server/src/services/TelegramClient.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
// @ts-check
const { default: fetch } = require('node-fetch')
const { TelegramStrategy } = require('@rainb0w-clwn/passport-telegram-official')
const { createRemoteJWKSet, jwtVerify } = require('jose')
const passport = require('passport')
const OAuth2Strategy = require('passport-oauth2')

const config = require('@rm/config')

Expand All @@ -11,12 +13,41 @@ const { webhookPerms } = require('../utils/webhookPerms')
const { scannerPerms, scannerCooldownBypass } = require('../utils/scannerPerms')
const { mergePerms } = require('../utils/mergePerms')
const { getUserDisplayName } = require('../utils/getUserDisplayName')
const { isOAuthStrategy } = require('../utils/getTelegramStrategy')
const { AuthClient } = require('./AuthClient')

/**
* @typedef {import('@rainb0w-clwn/passport-telegram-official/dist/types').PassportTelegramUser} TGUser
* @typedef {Parameters<import('@rainb0w-clwn/passport-telegram-official/dist/types').CallbackWithRequest>[0]} AuthRequest
*/

const TG_ISSUER = 'https://oauth.telegram.org'
const TG_AUTHORIZATION_URL = `${TG_ISSUER}/auth`
const TG_TOKEN_URL = `${TG_ISSUER}/token`
const TG_JWKS_URL = `${TG_ISSUER}/.well-known/jwks.json`

/**
* Telegram rotates its signing keys, so `jose` fetches and caches them lazily.
* They are not client specific, so every strategy shares one set.
*/
const getJwks = (() => {
/** @type {ReturnType<typeof createRemoteJWKSet>} */
let jwks
return () => {
if (!jwks) jwks = createRemoteJWKSet(new URL(TG_JWKS_URL))
return jwks
}
})()

/**
* Optional claims are absent when the user has not set them on their account.
*
* @param {unknown} claim
* @returns {string | undefined}
*/
const claimToString = (claim) =>
claim === undefined || claim === null ? undefined : String(claim)

class TelegramClient extends AuthClient {
/** @param {TGUser} user */
async getUserGroups(user) {
Expand Down Expand Up @@ -242,15 +273,114 @@ class TelegramClient extends AuthClient {
}
}

/**
* Telegram has no UserInfo endpoint - the profile is carried by the
* `id_token`, so it has to be verified against the JWKS before it is trusted.
*
* `sub` is an opaque, client specific identifier. The real Telegram user id
* only arrives as the `id` claim under the `profile` scope, and that is what
* `users.telegramId`, `strategy.groups`, `strategy.allowedUsers` and the
* `getChatMember` lookup all key off, so `sub` is unused here.
*
* @param {AuthRequest} req
* @param {Record<string, any>} params token endpoint response
* @param {(err: any, user?: any, info?: any) => void} done
*/
async oidcHandler(req, params, done) {
try {
if (!params?.id_token) {
throw new Error('No id_token was returned by Telegram')
}
const { payload } = await jwtVerify(params.id_token, getJwks(), {
issuer: TG_ISSUER,
audience: String(this.strategy.clientId),
})
if (!payload.id) {
throw new Error(
'The id_token has no `id` claim, the `profile` scope was not granted',
)
}
// `profile` always returns `name`; the `given_name`/`family_name` pair is
// only sometimes sent alongside it. Without the fallback a user with no
// @username would be displayed as their numeric id.
const [first, ...rest] = (claimToString(payload.name) ?? '').split(' ')
const firstName =
claimToString(payload.given_name) ?? (first || undefined)
const lastName =
claimToString(payload.family_name) ?? (rest.join(' ') || undefined)

return this.authHandler(
req,
// Not a complete PassportTelegramUser - `hash` and `auth_date` belong
// to the legacy widget
// @ts-ignore
{
// `telegramId` is a varchar and `groups` / `allowedUsers` hold
// string ids, so a number here would silently fail every comparison
id: String(payload.id),
username: claimToString(payload.preferred_username),
first_name: firstName,
last_name: lastName,
name: { givenName: firstName, familyName: lastName },
photo_url: claimToString(payload.picture),
provider: 'telegram',
},
done,
)
} catch (e) {
this.log.error('Unable to validate the Telegram id_token', e)
return done(null, false, { message: 'access_denied' })
}
}

initPassport() {
const { clientId, clientSecret, redirectUri } = this.strategy

if (!isOAuthStrategy(this.strategy)) {
if (clientId && clientSecret && !redirectUri) {
this.log.error(
'has a `clientId` and `clientSecret` but no `redirectUri`, so the OAuth flow cannot be started - falling back to the legacy login widget.',
`Add "redirectUri": "https://<your domain>/auth/${this.rmStrategy}/callback" to the strategy`,
'and register that same URL with @BotFather under Login Widget > Allowed URLs.',
)
}
// Legacy hash signed Login Widget, still supported by Telegram
passport.use(
this.rmStrategy,
new TelegramStrategy(
{
botToken: this.strategy.botToken,
passReqToCallback: true,
},
(req, profile, done) => this.authHandler(req, profile, done),
),
)
return
}

passport.use(
this.rmStrategy,
new TelegramStrategy(
new OAuth2Strategy(
{
botToken: this.strategy.botToken,
authorizationURL: TG_AUTHORIZATION_URL,
tokenURL: TG_TOKEN_URL,
clientID: clientId,
clientSecret,
callbackURL: redirectUri,
// `profile` is required, it is the only source of the Telegram user id
scope: ['openid', 'profile'],
// passport-oauth2 otherwise derives this from the authorization URL
// host, which every Telegram strategy shares, and concurrent logins
// would overwrite each other's `state` and PKCE verifier
sessionKey: `oauth2:telegram:${this.rmStrategy}`,
state: true,
pkce: 'S256',
passReqToCallback: true,
},
(req, profile, done) => this.authHandler(req, profile, done),
// passport-oauth2 only passes `params`, which carries the id_token, to
// a verify callback of this arity
(req, _accessToken, _refreshToken, params, _profile, done) =>
this.oidcHandler(req, params, done),
),
)
}
Expand Down
7 changes: 7 additions & 0 deletions server/src/utils/getServerSettings.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const config = require('@rm/config')
const { clientOptions } = require('../ui/clientOptions')
const { advMenus } = require('../ui/advMenus')
const { drawer } = require('../ui/drawer')
const { isTelegramOAuth } = require('./getTelegramStrategy')

/**
*
Expand Down Expand Up @@ -52,6 +53,12 @@ function getServerSettings(req) {
loggedIn: !!req.user,
excludeList: authentication.excludeFromTutorial,
methods: authentication.methods,
// customRoutes is per domain, and each domain can point at a different
// telegram strategy
telegramOAuth: isTelegramOAuth(
mapConfig.customRoutes.telegramAuthUrl,
authentication.strategies,
),
},
database: {
settings: {
Expand Down
Loading
Loading