From f2d642b74d37ae97795ed4d1216c2cf6df28dd82 Mon Sep 17 00:00:00 2001 From: PJ0tter Date: Sun, 5 Jul 2026 20:04:17 +0200 Subject: [PATCH 1/4] fix(scanArea): prevent crash when area feature has no name/key (#1225) * fix(scanArea): prevent crash when area feature has no name/key Guard the scan area search filter against features missing a properties.key (which happens when a scan area polygon has no name set), instead of throwing TypeError: Cannot read properties of undefined (reading 'toLowerCase'). Also fixes a longstanding typo (geoJsonFilName / geoJsonFilname -> geoJsonFileName) in the multi-domain example config and docs. * fix: copilot comments --------- Co-authored-by: Mygod --- config/multi-domain-example/README.md | 4 ++-- config/multi-domain-example/local-applemap.json | 2 +- config/multi-domain-example/local-orangemap.json | 2 +- src/features/scanArea/ScanAreaTile.jsx | 7 ++++--- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/config/multi-domain-example/README.md b/config/multi-domain-example/README.md index 8a1985437..4327c83df 100644 --- a/config/multi-domain-example/README.md +++ b/config/multi-domain-example/README.md @@ -4,7 +4,7 @@ - This makes use of the `NODE_CONFIG_ENV` env variable to determine which `local.json` files to load - Loads `default.json` => `local.json` => `local-{NODE_CONFIG_ENV}`.json -- You set all of your base defaults in `local.json` still, then set things that are unique to those domains, such `geoJsonFilename` or authentication strategies in each of the domain specifics jsons +- You set all of your base defaults in `local.json` still, then set things that are unique to those domains, such as `geoJsonFileName` or authentication strategies in each of the domain specifics jsons - The `NODE_CONFIG_ENV` var names should not contain `/` or `.` ## File System @@ -20,7 +20,7 @@ local - orangemap.json - `local.json` is the base config file that all other configs will inherit from, it can also be its own map instance if do not set the `NODE_CONFIG_ENV` env variable - The other files will inherit everything you set in `local.json` and then override any values that are set in the domain specific file -- Such as in `local-applemap.json`, we have set a new title, a separate Discord strategy, and a different geoJsonFilename +- Such as in `local-applemap.json`, we have set a new title, a separate Discord strategy, and a different geoJsonFileName - Only config setting you must set in each file is the port, since separate instances of the app will be generated - In `local-orangemap.json`, we also set a different start Latitude and Longitude and have disabled some various features that we do not want on that map. In `local.json`, we had set `alwaysEnabledPerms = ["map"]`, however, for orangemap we have overridden that by providing an empty array. - The databases specified in `local.json` will be used in all 3 maps, as will all of the permissions. diff --git a/config/multi-domain-example/local-applemap.json b/config/multi-domain-example/local-applemap.json index 5a7d1f59a..eef6ac5a9 100644 --- a/config/multi-domain-example/local-applemap.json +++ b/config/multi-domain-example/local-applemap.json @@ -4,7 +4,7 @@ "general": { "title": "Apple Map", "headerTitle": "Apple Map PoGo", - "geoJsonFilName": "http://koji.map.com/api/v1/geofence/feature-collection/apple" + "geoJsonFileName": "http://koji.map.com/api/v1/geofence/feature-collection/apple" }, "links": { "discordInvite": "apple map invite", diff --git a/config/multi-domain-example/local-orangemap.json b/config/multi-domain-example/local-orangemap.json index ccd8c9b17..0bafbf251 100644 --- a/config/multi-domain-example/local-orangemap.json +++ b/config/multi-domain-example/local-orangemap.json @@ -6,7 +6,7 @@ "headerTitle": "Orange Map", "startLat": 67.2512, "startLon": -25.9667, - "geoJsonFilName": "http://koji.map.com/api/v1/geofence/feature-collection/orange" + "geoJsonFileName": "http://koji.map.com/api/v1/geofence/feature-collection/orange" }, "misc": { "enableMapJsFilter": false, diff --git a/src/features/scanArea/ScanAreaTile.jsx b/src/features/scanArea/ScanAreaTile.jsx index 1cbfcb03d..0cebe5478 100644 --- a/src/features/scanArea/ScanAreaTile.jsx +++ b/src/features/scanArea/ScanAreaTile.jsx @@ -14,7 +14,8 @@ import { getProperName } from '@utils/strings' * @returns */ function ScanArea(featureCollection) { - const search = useStorage((s) => s.filters.scanAreas?.filter?.search) + const rawSearch = useStorage((s) => s.filters.scanAreas?.filter?.search ?? '') + const search = rawSearch.toLowerCase() const tapToToggle = useStorage((s) => s.userSettings.scanAreas.tapToToggle) const alwaysShowLabels = useStorage( (s) => s.userSettings.scanAreas.alwaysShowLabels, @@ -23,12 +24,12 @@ function ScanArea(featureCollection) { return ( webhook || search === '' || - f.properties.key.toLowerCase().includes(search.toLowerCase()) + (f.properties?.key || '').toLowerCase().includes(search) } eventHandlers={{ click: ({ propagatedFrom: layer }) => { From c8ff7d8d0647e004f1f00c550c2c430f9c020a39 Mon Sep 17 00:00:00 2001 From: Fabio1988 Date: Sun, 30 Aug 2026 14:25:19 +0200 Subject: [PATCH 2/4] feat(auth): support Telegram OAuth (OpenID Connect) Telegram now runs an OIDC provider at oauth.telegram.org, replacing the hash-signed Login Widget with an authorization-code + PKCE flow. The existing `telegram` strategy is upgraded in place rather than adding a new type: when a strategy has both `clientId` and `clientSecret`, TelegramClient registers a passport-oauth2 strategy against Telegram's endpoints; without them it keeps registering the legacy widget strategy. No config rename, no DB migration, no re-linking. The `id_token` is verified against Telegram's JWKS with `jose`, which covers all four signing algorithms BotFather offers (RS256, ES256, EdDSA, ES256K) and enforces signature, issuer and audience in one call. Telegram has no UserInfo endpoint, so the profile is read from the token claims and handed to the existing authHandler, leaving groups, perms, trials and account linking untouched. Identity comes from the `id` claim, not `sub`. `sub` is an opaque per-client identifier; `id` (profile scope) is the real Telegram user id that users.telegramId, strategy.groups, strategy.allowedUsers and the getChatMember lookup all key off, so existing accounts carry over. Client-side, a derived `authentication.telegramOAuth` flag tells the app which flow to render, since `authentication.methods` only carries strategy types. TelegramLogin picks the button or the widget from it, and the three call sites (login page, profile linking, login-page builder) share it. Also sends a cancelled Telegram consent screen to /blocked instead of letting passport's AuthorizationError surface as a 500. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QB1uHoTq84BLq1ikVBFheY --- config/default.json | 3 + config/local.example.json | 11 ++ package.json | 2 + packages/config/.configref | 2 +- packages/config/lib/mutations.js | 10 ++ packages/locales/lib/human/en.json | 1 + packages/types/lib/augmentations.d.ts | 8 ++ packages/types/lib/config.d.ts | 2 + server/src/routes/authRouter.js | 15 +++ server/src/services/TelegramClient.js | 122 +++++++++++++++++- server/src/utils/getServerSettings.js | 1 + src/assets/theme.js | 4 + src/components/Config.jsx | 1 + src/components/auth/Telegram.jsx | 56 ++++++++ src/features/builder/components/Generator.jsx | 4 +- src/features/profile/LinkAccounts.jsx | 9 +- src/pages/login/Methods.jsx | 4 +- src/store/useMemory.js | 2 + yarn.lock | 7 +- 19 files changed, 252 insertions(+), 12 deletions(-) diff --git a/config/default.json b/config/default.json index 2909f1f81..2ce3c53eb 100644 --- a/config/default.json +++ b/config/default.json @@ -745,6 +745,9 @@ "eventLogGroupId": "", "enabled": false, "botToken": "", + "clientId": "", + "clientSecret": "", + "redirectUri": "http://localhost:8080/auth/telegram/callback", "groups": [], "trialPeriod": { "start": { diff --git a/config/local.example.json b/config/local.example.json index 7ec7939d2..86ff57158 100644 --- a/config/local.example.json +++ b/config/local.example.json @@ -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": [ diff --git a/package.json b/package.json index ae0ad100b..d4a589b3a 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/packages/config/.configref b/packages/config/.configref index c0d681f70..4929b665d 100644 --- a/packages/config/.configref +++ b/packages/config/.configref @@ -1 +1 @@ -26052 \ No newline at end of file +26175 \ No newline at end of file diff --git a/packages/config/lib/mutations.js b/packages/config/lib/mutations.js index 3874caef2..214a49e1b 100644 --- a/packages/config/lib/mutations.js +++ b/packages/config/lib/mutations.js @@ -349,6 +349,16 @@ const applyMutations = (config) => { ), ] + // `methods` only carries strategy types, so the client cannot tell whether + // telegram is running the OAuth/OIDC flow or the legacy hash widget + config.authentication.telegramOAuth = config.authentication.strategies.some( + (strategy) => + strategy.enabled && + strategy.type === 'telegram' && + !!strategy.clientId && + !!strategy.clientSecret, + ) + if (Array.isArray(config.webhooks)) { config.webhooks = config.webhooks.map(replaceBothAliases) } diff --git a/packages/locales/lib/human/en.json b/packages/locales/lib/human/en.json index a4c26bcdc..b722b2c23 100644 --- a/packages/locales/lib/human/en.json +++ b/packages/locales/lib/human/en.json @@ -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", diff --git a/packages/types/lib/augmentations.d.ts b/packages/types/lib/augmentations.d.ts index 0d6ac029c..27f699573 100644 --- a/packages/types/lib/augmentations.d.ts +++ b/packages/types/lib/augmentations.d.ts @@ -51,6 +51,10 @@ declare module '@mui/material/styles' { fuchsia: string red: string } + telegram: { + main: string + contrastText: string + } } interface PaletteOptions { @@ -61,6 +65,10 @@ declare module '@mui/material/styles' { fuchsia: string red: string } + telegram?: { + main: string + contrastText: string + } } } diff --git a/packages/types/lib/config.d.ts b/packages/types/lib/config.d.ts index 15bbf422a..2889a99a4 100644 --- a/packages/types/lib/config.d.ts +++ b/packages/types/lib/config.d.ts @@ -61,6 +61,8 @@ export type Config = DeepMerge< alwaysEnabledPerms: string[] aliases: { role: string | string[]; name: string }[] methods: Strategy[] + /** Derived: a telegram strategy has both a `clientId` and a `clientSecret` */ + telegramOAuth: boolean strategies: { type: Strategy trialPeriod: { diff --git a/server/src/routes/authRouter.js b/server/src/routes/authRouter.js index 71b0a391e..bdb0093f6 100644 --- a/server/src/routes/authRouter.js +++ b/server/src/routes/authRouter.js @@ -70,6 +70,21 @@ const loadAuthStrategies = () => { delete req.session.discordPromptRetry } + // Telegram's OAuth flow reports a cancelled consent screen as an error + // param, which passport-oauth2 turns into a thrown AuthorizationError. + // Send those to /blocked, the same place a rejected login ends up. + if ( + strategy.type === 'telegram' && + typeof req.query.error === 'string' + ) { + log.debug(TAGS.auth, 'Telegram auth was denied:', req.query.error) + return res.redirect( + `/blocked/${encodeURIComponent( + new URLSearchParams({ message: 'access_denied' }).toString(), + )}`, + ) + } + return passport.authenticate( name, getAuthenticateOptions(req), diff --git a/server/src/services/TelegramClient.js b/server/src/services/TelegramClient.js index 30a2df836..51cb7619e 100644 --- a/server/src/services/TelegramClient.js +++ b/server/src/services/TelegramClient.js @@ -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') @@ -15,8 +17,38 @@ const { AuthClient } = require('./AuthClient') /** * @typedef {import('@rainb0w-clwn/passport-telegram-official/dist/types').PassportTelegramUser} TGUser + * @typedef {Parameters[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 the set is fetched lazily and cached + * by `jose` rather than pinned at boot. Shared across every telegram strategy + * since the keys are not client specific. + */ +const getJwks = (() => { + /** @type {ReturnType} */ + let jwks + return () => { + if (!jwks) jwks = createRemoteJWKSet(new URL(TG_JWKS_URL)) + return jwks + } +})() + +/** + * JWT claims are `unknown` until narrowed, and the optional ones are simply + * absent when the user has not set them on their Telegram 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) { @@ -242,15 +274,99 @@ class TelegramClient extends AuthClient { } } + /** + * Telegram's OpenID Connect provider has no UserInfo endpoint - the profile + * is carried by the `id_token` returned from the token exchange, so it has to + * be verified against the JWKS before anything in it is trusted. + * + * The `sub` claim is an opaque, client specific identifier. The actual + * Telegram user id only arrives as the `id` claim under the `profile` scope, + * and that is what the rest of ReactMap keys off of (`users.telegramId`, + * `strategy.groups`, `strategy.allowedUsers`, the `getChatMember` lookup), so + * `sub` is deliberately ignored. + * + * @param {AuthRequest} req + * @param {Record} 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', + ) + } + const firstName = claimToString(payload.given_name) + const lastName = claimToString(payload.family_name) + + return this.authHandler( + req, + // The OIDC flow has no `hash` or `auth_date` - those belong to the + // legacy widget - so this is not a complete PassportTelegramUser + // @ts-ignore + { + // String, to match both the `telegramId` varchar column and the + // string role ids that `groups` / `allowedUsers` are compared against + 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 } = this.strategy + + if (!clientId || !clientSecret) { + // 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: this.strategy.redirectUri, + // `profile` is required, it is the only source of the Telegram user id + scope: ['openid', 'profile'], + state: true, + pkce: 'S256', passReqToCallback: true, }, - (req, profile, done) => this.authHandler(req, profile, done), + // The 6 argument arity is what makes passport-oauth2 hand us `params`, + // which is where the id_token lives + (req, _accessToken, _refreshToken, params, _profile, done) => + this.oidcHandler(req, params, done), ), ) } diff --git a/server/src/utils/getServerSettings.js b/server/src/utils/getServerSettings.js index 790845db0..606e4de9e 100644 --- a/server/src/utils/getServerSettings.js +++ b/server/src/utils/getServerSettings.js @@ -52,6 +52,7 @@ function getServerSettings(req) { loggedIn: !!req.user, excludeList: authentication.excludeFromTutorial, methods: authentication.methods, + telegramOAuth: authentication.telegramOAuth, }, database: { settings: { diff --git a/src/assets/theme.js b/src/assets/theme.js index cc0be35b7..a54f0fda5 100644 --- a/src/assets/theme.js +++ b/src/assets/theme.js @@ -192,6 +192,10 @@ export function useCustomTheme() { fuchsia: '#EB459E', red: '#ED4245', }, + telegram: { + main: '#2AABEE', + contrastText: '#fff', + }, }, components, }), diff --git a/src/components/Config.jsx b/src/components/Config.jsx index 6fe50a58b..7595f60e9 100644 --- a/src/components/Config.jsx +++ b/src/components/Config.jsx @@ -110,6 +110,7 @@ export function Config({ children }) { loggedIn: !!data.user?.loggedIn, perms: data.user ? data.user.perms : {}, methods: data.authentication.methods || [], + telegramOAuth: !!data.authentication.telegramOAuth, username: data.user?.username || '', data: data.user?.data ? typeof data.user?.data === 'string' diff --git a/src/components/auth/Telegram.jsx b/src/components/auth/Telegram.jsx index 31ffdb601..c46ce2861 100644 --- a/src/components/auth/Telegram.jsx +++ b/src/components/auth/Telegram.jsx @@ -1,7 +1,16 @@ // @ts-check import * as React from 'react' +import Button from '@mui/material/Button' +import { useTranslation } from 'react-i18next' + +import { useMemory } from '@store/useMemory' + +import { I } from '../I' /** + * Legacy hash signed Login Widget. Telegram has archived its documentation in + * favor of the OAuth/OIDC flow, but it still works, so it stays the default for + * anyone who has not set a `clientId`/`clientSecret` on their strategy. * * @param {{ botName: string, authUrl: string }} props * @returns @@ -34,3 +43,50 @@ export function TelegramWidget({ botName, authUrl }) { return
} + +/** + * OAuth/OIDC entry point. Like Discord, the href points at the callback route, + * which passport redirects away from when there is no `code` in the query. + * + * @param {{ children?: string, bgcolor?: string } & import('@mui/material/Button').ButtonProps} props + * @returns {React.JSX.Element} + */ +export function TelegramButton({ + href = '/auth/telegram/callback', + children = 'login', + size = 'large', + bgcolor = 'telegram.main', + ...props +}) { + const { t } = useTranslation() + + return ( + // TODO: Augment Mui Types + + ) +} + +/** + * Renders whichever Telegram flow the server is configured for. + * + * @param {{ botName: string, authUrl: string } & Omit[0], 'href'>} props + * @returns + */ +export function TelegramLogin({ botName, authUrl, ...props }) { + const telegramOAuth = useMemory((s) => s.auth.telegramOAuth) + + return telegramOAuth ? ( + + ) : ( + + ) +} diff --git a/src/features/builder/components/Generator.jsx b/src/features/builder/components/Generator.jsx index eb52e499b..b11e6ceb9 100644 --- a/src/features/builder/components/Generator.jsx +++ b/src/features/builder/components/Generator.jsx @@ -6,7 +6,7 @@ import Grid from '@mui/material/Unstable_Grid2' import { DiscordButton } from '@components/auth/Discord' import { LocalLogin } from '@components/auth/Local' -import { TelegramWidget } from '@components/auth/Telegram' +import { TelegramLogin } from '@components/auth/Telegram' import { Img } from '@components/Img' import { LocaleSelection } from '@components/inputs/LocaleSelection' @@ -46,7 +46,7 @@ export function Generator({ block, defaultReturn = null }) { return case 'telegram': return ( - diff --git a/src/features/profile/LinkAccounts.jsx b/src/features/profile/LinkAccounts.jsx index ba89f7b87..3408e4364 100644 --- a/src/features/profile/LinkAccounts.jsx +++ b/src/features/profile/LinkAccounts.jsx @@ -11,7 +11,7 @@ import { useMemory } from '@store/useMemory' import { Query } from '@services/queries' import { METHODS } from '@assets/constants' import { DiscordButton } from '@components/auth/Discord' -import { TelegramWidget } from '@components/auth/Telegram' +import { TelegramLogin } from '@components/auth/Telegram' import { Notification } from '@components/Notification' import { getProperName } from '@utils/strings' @@ -36,10 +36,13 @@ export function LinkAccounts() { {METHODS.map((method, i) => { if (!auth.methods.includes(method)) return null const Component = i ? ( - + size="medium" + > + link_telegram + ) : ( {t('link_discord')} diff --git a/src/pages/login/Methods.jsx b/src/pages/login/Methods.jsx index d2ecf117a..4776af004 100644 --- a/src/pages/login/Methods.jsx +++ b/src/pages/login/Methods.jsx @@ -4,7 +4,7 @@ import Grid from '@mui/material/Unstable_Grid2' import { useTranslation } from 'react-i18next' import { DiscordButton } from '@components/auth/Discord' -import { TelegramWidget } from '@components/auth/Telegram' +import { TelegramLogin } from '@components/auth/Telegram' import { LocalLogin } from '@components/auth/Local' import { useMemory } from '@store/useMemory' @@ -46,7 +46,7 @@ function Telegram() { ) return ( - + ) } diff --git a/src/store/useMemory.js b/src/store/useMemory.js index d4ed1630c..67a4903bd 100644 --- a/src/store/useMemory.js +++ b/src/store/useMemory.js @@ -18,6 +18,7 @@ import { create } from 'zustand' * perms: Partial, * loggedIn: boolean, * methods: import('@rm/types').Strategy[], + * telegramOAuth: boolean, * strategy: import('@rm/types').Strategy | '', * userBackupLimits: number, * excludeList: string[], @@ -92,6 +93,7 @@ export const useMemory = create(() => ({ loggedIn: false, perms: {}, methods: [], + telegramOAuth: false, username: '', data: {}, counts: { diff --git a/yarn.lock b/yarn.lock index f6208cfcd..8044e5c7a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6280,6 +6280,11 @@ jiti@^2.4.1: resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.5.1.tgz#bd099c1c2be1c59bbea4e5adcd127363446759d0" integrity sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w== +jose@^5.9.6: + version "5.10.0" + resolved "https://registry.yarnpkg.com/jose/-/jose-5.10.0.tgz#c37346a099d6467c401351a9a0c2161e0f52c4be" + integrity sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg== + "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -8078,7 +8083,7 @@ passport-local@^1.0.0: dependencies: passport-strategy "1.x.x" -passport-oauth2@^1.5.0, passport-oauth2@^1.7.0: +passport-oauth2@^1.5.0, passport-oauth2@^1.7.0, passport-oauth2@^1.8.0: version "1.8.0" resolved "https://registry.yarnpkg.com/passport-oauth2/-/passport-oauth2-1.8.0.tgz#55725771d160f09bbb191828d5e3d559eee079c8" integrity sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA== From 80d6f261df2c08c05d5d8ba9716512b788a8aa46 Mon Sep 17 00:00:00 2001 From: Fabio1988 Date: Sun, 30 Aug 2026 14:43:18 +0200 Subject: [PATCH 3/4] fix(auth): resolve the Telegram flow per route, not per strategy list The `telegramOAuth` flag was derived with `some()` over every enabled telegram strategy, so a config running two of them - one legacy widget, one OAuth - reported OAuth for both. The login page renders a single control pointed at `map.customRoutes.telegramAuthUrl`, so the legacy route got a redirect link instead of the widget script and login failed. Which flow a control needs is a property of the one strategy behind its route, so resolve it from the auth URL instead. That also makes it correct for multiDomain, where customRoutes is per domain and each domain can target a different telegram strategy - hence the move out of the global config mutations and into getServerSettings, which has the per-request map config. An auth URL that does not resolve by name (a custom or proxied path) falls back to the only enabled telegram strategy when there is exactly one, and to the legacy widget when it is ambiguous. Reported by chatgpt-codex-connector on #1251. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QB1uHoTq84BLq1ikVBFheY --- packages/config/lib/mutations.js | 10 -- packages/types/lib/config.d.ts | 2 - server/src/utils/getServerSettings.js | 8 +- server/src/utils/getTelegramStrategy.js | 68 +++++++++++ .../test/telegramStrategyResolution.test.js | 110 ++++++++++++++++++ 5 files changed, 185 insertions(+), 13 deletions(-) create mode 100644 server/src/utils/getTelegramStrategy.js create mode 100644 server/test/telegramStrategyResolution.test.js diff --git a/packages/config/lib/mutations.js b/packages/config/lib/mutations.js index 214a49e1b..3874caef2 100644 --- a/packages/config/lib/mutations.js +++ b/packages/config/lib/mutations.js @@ -349,16 +349,6 @@ const applyMutations = (config) => { ), ] - // `methods` only carries strategy types, so the client cannot tell whether - // telegram is running the OAuth/OIDC flow or the legacy hash widget - config.authentication.telegramOAuth = config.authentication.strategies.some( - (strategy) => - strategy.enabled && - strategy.type === 'telegram' && - !!strategy.clientId && - !!strategy.clientSecret, - ) - if (Array.isArray(config.webhooks)) { config.webhooks = config.webhooks.map(replaceBothAliases) } diff --git a/packages/types/lib/config.d.ts b/packages/types/lib/config.d.ts index 2889a99a4..15bbf422a 100644 --- a/packages/types/lib/config.d.ts +++ b/packages/types/lib/config.d.ts @@ -61,8 +61,6 @@ export type Config = DeepMerge< alwaysEnabledPerms: string[] aliases: { role: string | string[]; name: string }[] methods: Strategy[] - /** Derived: a telegram strategy has both a `clientId` and a `clientSecret` */ - telegramOAuth: boolean strategies: { type: Strategy trialPeriod: { diff --git a/server/src/utils/getServerSettings.js b/server/src/utils/getServerSettings.js index 606e4de9e..5e75852ac 100644 --- a/server/src/utils/getServerSettings.js +++ b/server/src/utils/getServerSettings.js @@ -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') /** * @@ -52,7 +53,12 @@ function getServerSettings(req) { loggedIn: !!req.user, excludeList: authentication.excludeFromTutorial, methods: authentication.methods, - telegramOAuth: authentication.telegramOAuth, + // Resolved per domain, since customRoutes is part of the domain's map + // config and each domain can target a different telegram strategy + telegramOAuth: isTelegramOAuth( + mapConfig.customRoutes.telegramAuthUrl, + authentication.strategies, + ), }, database: { settings: { diff --git a/server/src/utils/getTelegramStrategy.js b/server/src/utils/getTelegramStrategy.js new file mode 100644 index 000000000..11716b60a --- /dev/null +++ b/server/src/utils/getTelegramStrategy.js @@ -0,0 +1,68 @@ +// @ts-check + +/** + * The client renders a single Telegram login control per domain, pointed at + * `map.customRoutes.telegramAuthUrl`. Which flow that control has to use - the + * OAuth/OIDC redirect or the legacy hash signed widget - is a property of the + * one strategy sitting behind that route, not of the strategy list as a whole, + * since a config can enable several Telegram strategies at once and a + * multiDomain setup can point each domain at a different one. + * + * @param {string} authUrl the configured `telegramAuthUrl` + * @returns {string | null} the strategy name the route resolves to + */ +function getStrategyNameFromAuthUrl(authUrl) { + if (!authUrl) return null + // Tolerate absolute URLs, the config allows either form + const pathname = authUrl.startsWith('http') + ? URL.canParse(authUrl) + ? new URL(authUrl).pathname + : '' + : authUrl + const segments = pathname.split('/').filter(Boolean) + const authIndex = segments.lastIndexOf('auth') + // `/auth/` and `/auth//callback` are the two shapes authRouter + // registers, so the name is always the segment right after `auth` + return authIndex === -1 ? null : (segments[authIndex + 1] ?? null) +} + +/** + * Resolves the Telegram strategy that a login control points at. + * + * @param {string} authUrl the configured `telegramAuthUrl` + * @param {import('@rm/types').StrategyConfig[]} strategies + * @returns {import('@rm/types').StrategyConfig | null} + */ +function getTelegramStrategy(authUrl, strategies) { + const enabled = strategies.filter((s) => s.enabled && s.type === 'telegram') + if (!enabled.length) return null + + const name = getStrategyNameFromAuthUrl(authUrl) + const byName = name ? enabled.find((s) => s.name === name) : undefined + if (byName) return byName + + // A custom or proxied auth URL will not resolve by name. With only one + // Telegram strategy there is no ambiguity, so use it - otherwise there is no + // way to tell which one the route means, and the legacy widget is the safer + // guess since it is what every pre-OAuth config already runs. + return enabled.length === 1 ? enabled[0] : null +} + +/** + * Whether the Telegram strategy behind a login control runs the OAuth/OIDC + * flow. Anything else falls back to the legacy hash signed widget. + * + * @param {string} authUrl the configured `telegramAuthUrl` + * @param {import('@rm/types').StrategyConfig[]} strategies + * @returns {boolean} + */ +function isTelegramOAuth(authUrl, strategies) { + const strategy = getTelegramStrategy(authUrl, strategies) + return !!(strategy?.clientId && strategy?.clientSecret) +} + +module.exports = { + getStrategyNameFromAuthUrl, + getTelegramStrategy, + isTelegramOAuth, +} diff --git a/server/test/telegramStrategyResolution.test.js b/server/test/telegramStrategyResolution.test.js new file mode 100644 index 000000000..45151a27a --- /dev/null +++ b/server/test/telegramStrategyResolution.test.js @@ -0,0 +1,110 @@ +const assert = require('node:assert/strict') +const { test } = require('node:test') + +const { + getStrategyNameFromAuthUrl, + getTelegramStrategy, + isTelegramOAuth, +} = require('../src/utils/getTelegramStrategy') + +/** @param {object} overrides */ +const telegram = (overrides) => ({ + name: 'telegram', + type: 'telegram', + enabled: true, + botToken: '1:A', + clientId: '', + clientSecret: '', + ...overrides, +}) + +const LEGACY = telegram({ name: 'telegram' }) +const OAUTH = telegram({ + name: 'telegram-oauth', + clientId: '123', + clientSecret: 'shh', +}) + +test('resolves the strategy name from both route shapes', () => { + assert.equal( + getStrategyNameFromAuthUrl('/auth/telegram/callback'), + 'telegram', + ) + assert.equal(getStrategyNameFromAuthUrl('/auth/telegram'), 'telegram') + assert.equal( + getStrategyNameFromAuthUrl('https://map.example/auth/tg-two/callback'), + 'tg-two', + ) + assert.equal(getStrategyNameFromAuthUrl(''), null) + assert.equal(getStrategyNameFromAuthUrl('/login'), null) + assert.equal(getStrategyNameFromAuthUrl('http://['), null) +}) + +test('picks the strategy the auth url points at, not just any telegram one', () => { + const strategies = [LEGACY, OAUTH] + + assert.equal( + getTelegramStrategy('/auth/telegram/callback', strategies).name, + 'telegram', + ) + assert.equal( + getTelegramStrategy('/auth/telegram-oauth/callback', strategies).name, + 'telegram-oauth', + ) +}) + +test('a second OAuth strategy does not flip the legacy route to OAuth', () => { + const strategies = [LEGACY, OAUTH] + + // the regression: `some()` over all strategies reported OAuth here, so the + // login page rendered a redirect link for a route running the hash widget + assert.equal(isTelegramOAuth('/auth/telegram/callback', strategies), false) + assert.equal( + isTelegramOAuth('/auth/telegram-oauth/callback', strategies), + true, + ) +}) + +test('a single telegram strategy resolves even from an unrecognized url', () => { + assert.equal(isTelegramOAuth('/custom/proxy/path', [OAUTH]), true) + assert.equal(isTelegramOAuth('/custom/proxy/path', [LEGACY]), false) +}) + +test('an ambiguous unrecognized url falls back to the legacy widget', () => { + assert.equal(isTelegramOAuth('/custom/proxy/path', [LEGACY, OAUTH]), false) +}) + +test('disabled strategies are ignored', () => { + const disabledOAuth = { ...OAUTH, name: 'telegram', enabled: false } + + assert.equal( + getTelegramStrategy('/auth/telegram/callback', [disabledOAuth]), + null, + ) + assert.equal( + isTelegramOAuth('/auth/telegram/callback', [disabledOAuth]), + false, + ) + assert.equal(isTelegramOAuth('/auth/telegram/callback', []), false) +}) + +test('half configured credentials stay on the legacy widget', () => { + const idOnly = telegram({ clientId: '123' }) + const secretOnly = telegram({ clientSecret: 'shh' }) + + assert.equal(isTelegramOAuth('/auth/telegram/callback', [idOnly]), false) + assert.equal(isTelegramOAuth('/auth/telegram/callback', [secretOnly]), false) +}) + +test('non telegram strategies never match', () => { + const discord = { + name: 'discord', + type: 'discord', + enabled: true, + clientId: '123', + clientSecret: 'shh', + } + + assert.equal(getTelegramStrategy('/auth/discord/callback', [discord]), null) + assert.equal(isTelegramOAuth('/auth/discord/callback', [discord]), false) +}) From dad213c15b04f557f175efae7dba5505ec7b8ef7 Mon Sep 17 00:00:00 2001 From: Fabio1988 Date: Sun, 30 Aug 2026 14:56:17 +0200 Subject: [PATCH 4/4] fix(auth): resolve the Telegram flow per custom login page block Custom login page blocks carry their own `telegramAuthUrl`, which can point at a different strategy than the domain's `customRoutes` default, so they cannot inherit the page level flag either. The customComponent resolver now annotates each telegram block with the flow resolved from that block's own route, recursing into parent blocks, and Generator passes it down. TelegramLogin prefers an explicitly resolved flow and falls back to the domain default when a caller does not supply one, so the login page and profile linking are unchanged. The block list is copied rather than mutated, since it comes straight off the shared config object. customComponent returns a JSON scalar, so the added field needs no schema change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QB1uHoTq84BLq1ikVBFheY --- packages/types/lib/blocks.d.ts | 2 + server/src/graphql/resolvers.js | 15 ++-- server/src/utils/getTelegramStrategy.js | 28 +++++++ .../test/telegramStrategyResolution.test.js | 81 +++++++++++++++++++ src/components/auth/Telegram.jsx | 16 ++-- src/features/builder/components/Generator.jsx | 1 + 6 files changed, 132 insertions(+), 11 deletions(-) diff --git a/packages/types/lib/blocks.d.ts b/packages/types/lib/blocks.d.ts index b5a2a1e17..1e01505b6 100644 --- a/packages/types/lib/blocks.d.ts +++ b/packages/types/lib/blocks.d.ts @@ -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 { diff --git a/server/src/graphql/resolvers.js b/server/src/graphql/resolvers.js index f27431974..e3ac01cbd 100644 --- a/server/src/graphql/resolvers.js +++ b/server/src/graphql/resolvers.js @@ -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') @@ -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 diff --git a/server/src/utils/getTelegramStrategy.js b/server/src/utils/getTelegramStrategy.js index 11716b60a..e3030bea7 100644 --- a/server/src/utils/getTelegramStrategy.js +++ b/server/src/utils/getTelegramStrategy.js @@ -61,7 +61,35 @@ function isTelegramOAuth(authUrl, strategies) { return !!(strategy?.clientId && strategy?.clientSecret) } +/** + * Custom login page blocks carry their own `telegramAuthUrl`, which can point + * at a different strategy than the domain's `customRoutes` default, so each + * block gets its flow resolved from its own route rather than inheriting the + * page level flag. + * + * @param {import("@rm/types").CustomComponent[]} components + * @param {import('@rm/types').StrategyConfig[]} strategies + * @returns {import("@rm/types").CustomComponent[]} + */ +function annotateTelegramBlocks(components, strategies) { + return (Array.isArray(components) ? components : []).map((component) => { + if ('components' in component && Array.isArray(component.components)) { + return { + ...component, + components: annotateTelegramBlocks(component.components, strategies), + } + } + return component.type === 'telegram' + ? { + ...component, + telegramOAuth: isTelegramOAuth(component.telegramAuthUrl, strategies), + } + : component + }) +} + module.exports = { + annotateTelegramBlocks, getStrategyNameFromAuthUrl, getTelegramStrategy, isTelegramOAuth, diff --git a/server/test/telegramStrategyResolution.test.js b/server/test/telegramStrategyResolution.test.js index 45151a27a..3f39f3b29 100644 --- a/server/test/telegramStrategyResolution.test.js +++ b/server/test/telegramStrategyResolution.test.js @@ -2,6 +2,7 @@ const assert = require('node:assert/strict') const { test } = require('node:test') const { + annotateTelegramBlocks, getStrategyNameFromAuthUrl, getTelegramStrategy, isTelegramOAuth, @@ -108,3 +109,83 @@ test('non telegram strategies never match', () => { assert.equal(getTelegramStrategy('/auth/discord/callback', [discord]), null) assert.equal(isTelegramOAuth('/auth/discord/callback', [discord]), false) }) + +test('annotates custom login page blocks from their own auth url', () => { + const strategies = [LEGACY, OAUTH] + // the shape people actually have in their loginPage config + const blocks = [ + { + type: 'telegram', + gridSizes: { sm: 6 }, + telegramBotName: 'CandyMapBot', + telegramAuthUrl: '/auth/telegram/callback', + gridStyle: { marginTop: 20, textDecoration: 'none' }, + }, + { + type: 'telegram', + telegramBotName: 'CandyMapBot', + telegramAuthUrl: '/auth/telegram-oauth/callback', + }, + { type: 'discord', link: '/auth/discord/callback' }, + ] + + const [legacyBlock, oauthBlock, discordBlock] = annotateTelegramBlocks( + blocks, + strategies, + ) + + assert.equal(legacyBlock.telegramOAuth, false) + assert.equal(oauthBlock.telegramOAuth, true) + assert.equal('telegramOAuth' in discordBlock, false) + // every other key on the block survives untouched + assert.deepEqual(legacyBlock.gridSizes, { sm: 6 }) + assert.equal(legacyBlock.telegramBotName, 'CandyMapBot') + assert.deepEqual(legacyBlock.gridStyle, { + marginTop: 20, + textDecoration: 'none', + }) +}) + +test('annotates telegram blocks nested inside parent blocks', () => { + const annotated = annotateTelegramBlocks( + [ + { + type: 'parent', + components: [ + { type: 'telegram', telegramAuthUrl: '/auth/telegram/callback' }, + { + type: 'parent', + components: [ + { + type: 'telegram', + telegramAuthUrl: '/auth/telegram-oauth/callback', + }, + ], + }, + ], + }, + ], + [LEGACY, OAUTH], + ) + + assert.equal(annotated[0].components[0].telegramOAuth, false) + assert.equal(annotated[0].components[1].components[0].telegramOAuth, true) +}) + +test('annotating does not mutate the config objects it is given', () => { + const block = { + type: 'telegram', + telegramAuthUrl: '/auth/telegram-oauth/callback', + } + const blocks = [block] + + annotateTelegramBlocks(blocks, [LEGACY, OAUTH]) + + assert.equal('telegramOAuth' in block, false) + assert.equal(blocks[0], block) +}) + +test('annotating tolerates a missing or empty component list', () => { + assert.deepEqual(annotateTelegramBlocks(undefined, [OAUTH]), []) + assert.deepEqual(annotateTelegramBlocks([], [OAUTH]), []) +}) diff --git a/src/components/auth/Telegram.jsx b/src/components/auth/Telegram.jsx index c46ce2861..ee38a689f 100644 --- a/src/components/auth/Telegram.jsx +++ b/src/components/auth/Telegram.jsx @@ -76,15 +76,21 @@ export function TelegramButton({ } /** - * Renders whichever Telegram flow the server is configured for. + * Renders whichever Telegram flow the route in `authUrl` is running. * - * @param {{ botName: string, authUrl: string } & Omit[0], 'href'>} props + * Custom login page blocks carry their own `telegramAuthUrl`, which can point + * at a different strategy than the domain default, so they pass the flow the + * server resolved for that block. Everything else uses the flow resolved for + * `customRoutes.telegramAuthUrl`. + * + * @param {{ botName: string, authUrl: string, telegramOAuth?: boolean } & Omit[0], 'href'>} props * @returns */ -export function TelegramLogin({ botName, authUrl, ...props }) { - const telegramOAuth = useMemory((s) => s.auth.telegramOAuth) +export function TelegramLogin({ botName, authUrl, telegramOAuth, ...props }) { + const domainDefault = useMemory((s) => s.auth.telegramOAuth) + const isOAuth = telegramOAuth ?? domainDefault - return telegramOAuth ? ( + return isOAuth ? ( ) : ( diff --git a/src/features/builder/components/Generator.jsx b/src/features/builder/components/Generator.jsx index b11e6ceb9..4d5527425 100644 --- a/src/features/builder/components/Generator.jsx +++ b/src/features/builder/components/Generator.jsx @@ -49,6 +49,7 @@ export function Generator({ block, defaultReturn = null }) { ) case 'discord':