Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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
19 changes: 9 additions & 10 deletions apps/docs_web/docs/v0/sdk-usage/mobile/react-native-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,21 +169,20 @@ AsyncStorage state is restored.
### Sign In

```typescript
// type: "google" | "email" | "discord" | "github" | "x"
// type: "google" | "email" | "discord" | "github" | "x" | "telegram"
await wallet.signIn("google");
```

**Supported sign-in types:**

| Type | Provider |
| ----------- | ----------------- |
| `"google"` | Google OAuth |
| `"email"` | Email sign-in |
| `"discord"` | Discord OAuth |
| `"github"` | GitHub OAuth |
| `"x"` | X (Twitter) OAuth |

> Telegram sign-in support is coming soon.
| Type | Provider |
| ------------- | ------------------ |
| `"google"` | Google OAuth |
| `"email"` | Email sign-in |
| `"discord"` | Discord OAuth |
| `"github"` | GitHub OAuth |
| `"x"` | X (Twitter) OAuth |
| `"telegram"` | Telegram OIDC |

Calling `signIn()` opens the OS browser for the OAuth flow. On iOS and Android
fallback flows, the browser returns via your configured `redirectScheme`. On
Expand Down
2 changes: 2 additions & 0 deletions backend/oko_api/server/src/bin/launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ async function main() {
encryption_secret: envs.ENCRYPTION_SECRET!,
typeform_webhook_secret: envs.TYPEFORM_WEBHOOK_SECRET!,
telegram_bot_token: envs.TELEGRAM_BOT_TOKEN!,
telegram_client_id: envs.TELEGRAM_CLIENT_ID,
telegram_client_secret: envs.TELEGRAM_CLIENT_SECRET,
slack_webhook_url: envs.SLACK_WEBHOOK_URL ?? null,
ks_node_report_password: envs.KS_NODE_REPORT_PASSWORD!,
github_client_secret: envs.GITHUB_CLIENT_SECRET!,
Expand Down
2 changes: 2 additions & 0 deletions backend/oko_api/server/src/envs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ export const envSchema = z.object({
TYPEFORM_WEBHOOK_SECRET: z.string(),

TELEGRAM_BOT_TOKEN: z.string(),
TELEGRAM_CLIENT_ID: z.string().optional(),
TELEGRAM_CLIENT_SECRET: z.string().optional(),

SLACK_WEBHOOK_URL: z.string().optional(),
KS_NODE_REPORT_PASSWORD: z.string(),
Expand Down
8 changes: 6 additions & 2 deletions backend/oko_api/server/src/middleware/auth/jwks_cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ interface CacheEntry {

const JWKS_CACHE_TTL_MS = 10 * 60 * 1000;

export function createJwksCache(jwksUrl: string, provider: string) {
export function createJwksCache(
jwksUrl: string,
provider: string,
fetchInit?: RequestInit,
) {
const cache: CacheEntry = { fetchedAt: 0, keys: [] };

async function getSigningKey(kid: string): Promise<JwkWithKid | null> {
Expand All @@ -35,7 +39,7 @@ export function createJwksCache(jwksUrl: string, provider: string) {
return cache.keys;
}

const response = await fetch(jwksUrl);
const response = await fetch(jwksUrl, fetchInit);

if (!response.ok) {
throw new Error(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
type TelegramUserInfo,
validateTelegramHash,
} from "@oko-wallet-api/middleware/auth/telegram_auth/validate";
import { validateTelegramJwt } from "@oko-wallet-api/middleware/auth/telegram_auth/validate_jwt";
import type { OAuthLocals } from "@oko-wallet-api/middleware/auth/types";

export interface TelegramAuthenticatedRequest<T = any> extends Request {
Expand All @@ -31,6 +32,23 @@ export async function telegramAuthMiddleware(

const bearerToken = authHeader.substring(7).trim(); // skip "Bearer "

// Dual-validation: detect legacy JSON vs OIDC JWT
// JSON-stringified objects always start with "{", JWTs never do (they start with "eyJ")
if (bearerToken.startsWith("{")) {
// Legacy HMAC path
await handleLegacyHmac(req, res, next, bearerToken);
} else {
// OIDC JWT path
await handleJwt(req, res, next, bearerToken);
}
}

async function handleLegacyHmac(
req: TelegramAuthenticatedRequest,
res: Response<unknown, OAuthLocals>,
next: NextFunction,
bearerToken: string,
) {
let userData: TelegramUserData;
try {
userData = JSON.parse(bearerToken) as TelegramUserData;
Expand Down Expand Up @@ -74,7 +92,6 @@ export async function telegramAuthMiddleware(

res.locals.oauth_user = {
type: "telegram" as AuthType,
// in telegram, use telegram id as identifier with prefix
user_identifier: `telegram_${userInfo.id}`,
name: userInfo.username,
metadata: userInfo as unknown as Record<string, unknown>,
Expand All @@ -91,3 +108,49 @@ export async function telegramAuthMiddleware(
return;
}
}

async function handleJwt(
req: TelegramAuthenticatedRequest,
res: Response<unknown, OAuthLocals>,
next: NextFunction,
bearerToken: string,
) {
try {
const telegramClientId: string | undefined =
req.app.locals.telegram_client_id;
if (!telegramClientId) {
res.status(500).json({
error: "Telegram OIDC not configured (TELEGRAM_CLIENT_ID missing)",
});
return;
}
const result = await validateTelegramJwt(bearerToken, telegramClientId);

if (!result.success) {
res.status(401).json({ error: result.err });
return;
}

if (!result.data.id) {
res.status(401).json({
error: "Can't get id from Telegram JWT",
});
return;
}

res.locals.oauth_user = {
type: "telegram" as AuthType,
user_identifier: `telegram_${result.data.id}`,
name: result.data.username,
metadata: result.data as unknown as Record<string, unknown>,
};

next();
return;
} catch (error) {
res.status(500).json({
error: `JWT validation failed: ${error instanceof Error ? error.message : String(error)}`,
});
return;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import type { Result } from "@oko-wallet/stdlib-js";
import jwt, { type JwtHeader, type JwtPayload } from "jsonwebtoken";
import { Agent } from "undici";

import type { TelegramUserInfo } from "./validate";
import {
createJwksCache,
jwkToPem,
} from "@oko-wallet-api/middleware/auth/jwks_cache";

const TELEGRAM_OIDC_ISSUER = "https://oauth.telegram.org";
const TELEGRAM_JWKS_URL = "https://oauth.telegram.org/.well-known/jwks.json";

// oauth.telegram.org has AAAA records but IPv6 connectivity is unreliable.
// Force IPv4 to prevent intermittent ETIMEDOUT from Happy Eyeballs.
const telegramAgent = new Agent({
connect: { family: 4 } as Record<string, unknown>,
});
const telegramJwksCache = createJwksCache(TELEGRAM_JWKS_URL, "Telegram", {
// @ts-expect-error -- Node.js undici dispatcher, not in standard RequestInit
dispatcher: telegramAgent,
});

interface TelegramIdTokenPayload extends JwtPayload {
id?: number;
preferred_username?: string;
name?: string;
picture?: string;
}

export async function validateTelegramJwt(
idToken: string,
telegramClientId: string,
): Promise<Result<TelegramUserInfo, string>> {
try {
const decoded = jwt.decode(idToken, { complete: true });

if (!decoded || typeof decoded === "string") {
return {
success: false,
err: "Invalid token format",
};
}

const header = decoded.header as JwtHeader;

if (!header.kid) {
return {
success: false,
err: "Missing key id in token header",
};
}

const jwk = await telegramJwksCache.getSigningKey(header.kid);

if (!jwk) {
return {
success: false,
err: "Unable to find signing key for token",
};
}

const pem = jwkToPem(jwk);

const payload = jwt.verify(idToken, pem, {
algorithms: ["RS256"],
issuer: TELEGRAM_OIDC_ISSUER,
audience: telegramClientId,
}) as TelegramIdTokenPayload;

// Telegram OIDC sub is a pairwise identifier (differs per bot).
// Use the id claim (real Telegram user ID) for legacy compatibility.
const userId =
(payload.id != null ? String(payload.id) : undefined) ?? payload.sub;

if (!userId) {
return {
success: false,
err: "Missing user ID (sub) in token",
};
}

return {
success: true,
data: {
id: userId,
username: payload.preferred_username,
},
};
} catch (error) {
const message =
error instanceof Error ? error.message : "JWT validation failed";
return {
success: false,
err: message,
};
}
}
Loading
Loading