Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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(),
TELEGRAM_CLIENT_SECRET: z.string(),
Comment thread
lidarbtc marked this conversation as resolved.
Outdated

SLACK_WEBHOOK_URL: z.string().optional(),
KS_NODE_REPORT_PASSWORD: z.string(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,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 @@ -28,6 +29,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 @@ -63,7 +81,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 @@ -78,3 +95,42 @@ export async function telegramAuthMiddleware(
return;
}
}

async function handleJwt(
req: TelegramAuthenticatedRequest,
res: Response<unknown, OAuthLocals>,
next: NextFunction,
bearerToken: string,
) {
try {
const telegramClientId = req.app.locals.telegram_client_id;
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,87 @@
import type { Result } from "@oko-wallet/stdlib-js";
import jwt, { type JwtHeader, type JwtPayload } from "jsonwebtoken";

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";

const telegramJwksCache = createJwksCache(TELEGRAM_JWKS_URL, "Telegram");

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;

const userId =
payload.sub ?? (payload.id != null ? String(payload.id) : undefined);

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,
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { registry } from "@oko-wallet/oko-api-openapi";
import { ErrorResponseSchema } from "@oko-wallet/oko-api-openapi/common";
import {
SocialLoginTelegramRequestSchema,
SocialLoginTelegramSuccessResponseSchema,
} from "@oko-wallet/oko-api-openapi/social_login";
import type { OkoApiResponse } from "@oko-wallet/oko-types/api_response";
import type {
SocialLoginTelegramBody,
SocialLoginTelegramResponse,
} from "@oko-wallet/oko-types/social_login";
import type { Request, Response } from "express";

const TELEGRAM_OIDC_TOKEN_URL = "https://oauth.telegram.org/token";

registry.registerPath({
method: "post",
path: "/social-login/v1/telegram/get-token",
tags: ["Social Login"],
summary: "Get Telegram OIDC ID token",
description:
"Exchange authorization code for a Telegram OIDC ID token using PKCE",
request: {
body: {
required: true,
content: {
"application/json": {
schema: SocialLoginTelegramRequestSchema,
},
},
},
},
responses: {
200: {
description: "Successfully retrieved ID token",
content: {
"application/json": {
schema: SocialLoginTelegramSuccessResponseSchema,
},
},
},
400: {
description: "Invalid request",
content: {
"application/json": {
schema: ErrorResponseSchema,
},
},
},
500: {
description: "Server error",
content: {
"application/json": {
schema: ErrorResponseSchema,
},
},
},
},
});

export async function getTelegramToken(
req: Request<any, any, SocialLoginTelegramBody>,
res: Response<OkoApiResponse<SocialLoginTelegramResponse>>,
) {
const body = req.body;

if (!body.code || !body.code_verifier || !body.redirect_uri) {
res.status(400).json({
success: false,
code: "INVALID_REQUEST",
msg: "Code, code_verifier, or redirect_uri is not set",
});
return;
}

try {
const reqBody = new URLSearchParams({
code: body.code,
grant_type: "authorization_code",
client_id: req.app.locals.telegram_client_id,
client_secret: req.app.locals.telegram_client_secret,
redirect_uri: body.redirect_uri,
code_verifier: body.code_verifier,
});

const response = await fetch(TELEGRAM_OIDC_TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
Comment thread
lidarbtc marked this conversation as resolved.
body: reqBody,
});

if (response.status === 200) {
const data: {
id_token?: string;
error?: string;
error_description?: string;
} = await response.json();

if (data.error) {
res.status(400).json({
success: false,
code: "UNKNOWN_ERROR",
msg: `${data.error}: ${data.error_description}`,
});
return;
}

if (!data.id_token) {
res.status(400).json({
success: false,
code: "UNKNOWN_ERROR",
msg: "Telegram OIDC response missing id_token",
});
return;
}

res.status(200).json({
success: true,
data: {
id_token: data.id_token,
},
});
return;
}

res.status(response.status).json({
success: false,
code: "UNKNOWN_ERROR",
msg: await response.text(),
});
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : "Failed to exchange Telegram token";
res.status(500).json({
success: false,
code: "UNKNOWN_ERROR",
msg: message,
});
}
}
7 changes: 7 additions & 0 deletions backend/oko_api/server/src/routes/social_login_v1/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import express from "express";

import { getGithubToken } from "./get_github_token";
import { getTelegramToken } from "./get_telegram_token";
import { getXToken } from "./get_x_token";
import { referralCivitia } from "./referral_civitia";
import { saveReferral } from "./save_referral";
Expand All @@ -23,6 +24,12 @@ export function makeSocialLoginRouter() {
getGithubToken,
);

router.post(
"/telegram/get-token",
rateLimitMiddleware({ windowSeconds: 60, maxRequests: 10 }),
getTelegramToken,
);

router.get(
"/x/verify-user",
rateLimitMiddleware({ windowSeconds: 60, maxRequests: 10 }),
Expand Down
Loading
Loading