Skip to content

Commit 4d4492c

Browse files
kevintsengclaude
andcommitted
feat: comprehensive security and type safety improvements
Security fixes (P0): - Use timing-safe comparison for webhook signature verification - Remove signature bypass vulnerability (throw error instead of warn+continue) Feature improvements (P1): - Export multicast and richMenu from @linekit/messaging - Add input validation for reply, push, multicast functions - Add proper Message type definitions with all LINE message types Type safety (P2): - Replace `any` with proper types across all packages - Add isReplyableEvent type guard for Context - Add WebhookRequest/WebhookResponse interfaces for router - Add LineAPIErrorResponse type for error handling - Add RichMenuAction interface Error handling (P2): - Add LineLoginError class to @linekit/login - Handle JSON.parse failures in client.ts - Add onError callback option to router OAuth helpers (P3): - Add generateState() for CSRF protection - Add validateState() with timing-safe comparison - Add generateNonce() for ID token validation Documentation: - Update README with security features - Add LINE Login OAuth flow example with state validation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent d3dbf07 commit 4d4492c

17 files changed

Lines changed: 370 additions & 68 deletions

File tree

README.md

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ A modular integration toolkit for LINE Messaging API, Login, and LIFF.
1212
- **Modular**: separate packages for Core, Messaging, Login, and Adapters.
1313
- **Framework Agnostic**: use with Express, Fastify, or standard Web APIs.
1414
- **Type-Safe**: written in TypeScript with complete definitions.
15+
- **Secure**: timing-safe signature verification, input validation, CSRF protection helpers.
1516
- **Developer Friendly**: simple, explicit API for bots and login.
1617

1718
## Installation
@@ -59,18 +60,29 @@ app.listen(3000, () => console.log("Bot running on port 3000"));
5960
### LINE Login
6061

6162
```ts
62-
import { login } from "@linekit/login";
63-
64-
// Verify ID Token from client
65-
const user = await login.verify(idToken, channelId);
66-
console.log(user.name, user.email);
63+
import { login, generateAuthUrl, issueAccessToken } from "@linekit/login";
64+
65+
// Generate OAuth URL with CSRF protection
66+
const state = login.generateState();
67+
const authUrl = generateAuthUrl({
68+
channelId: "YOUR_CHANNEL_ID",
69+
redirectUri: "https://example.com/callback",
70+
state,
71+
});
72+
73+
// After callback, validate state and exchange code for tokens
74+
if (login.validateState(savedState, returnedState)) {
75+
const tokens = await issueAccessToken(channelId, channelSecret, code, redirectUri);
76+
const user = await login.verify(tokens.id_token, channelId);
77+
console.log(user.name, user.email);
78+
}
6779
```
6880

6981
## Packages
7082

71-
- **@linekit/core**: Webhook verification, Context, Router.
72-
- **@linekit/messaging**: Messaging API client (Reply, Push, Multicast).
73-
- **@linekit/login**: OAuth and ID Token verification.
83+
- **@linekit/core**: Webhook verification (timing-safe), Context, Router with error handling.
84+
- **@linekit/messaging**: Messaging API client (Reply, Push, Multicast, Rich Menu) with input validation.
85+
- **@linekit/login**: OAuth flow, ID Token verification, CSRF state helpers.
7486
- **@linekit/express**: Adapter for Express.js.
7587

7688
## Documentation

README.zh-TW.md

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
- **模組化**:將 Core、Messaging、Login 和 Adapters 分離為獨立套件。
1313
- **框架無關**:可與 Express、Fastify 或標準 Web API 一起使用。
1414
- **型別安全**:使用 TypeScript 編寫,提供完整的型別定義。
15+
- **安全性**:時序安全的簽章驗證、輸入驗證、CSRF 保護工具。
1516
- **開發者友善**:為機器人和登入功能提供簡單、明確的 API。
1617

1718
## 安裝
@@ -59,18 +60,29 @@ app.listen(3000, () => console.log("Bot running on port 3000"));
5960
### LINE Login
6061

6162
```ts
62-
import { login } from "@linekit/login";
63-
64-
// 驗證來自客戶端的 ID Token
65-
const user = await login.verify(idToken, channelId);
66-
console.log(user.name, user.email);
63+
import { login, generateAuthUrl, issueAccessToken } from "@linekit/login";
64+
65+
// 產生含 CSRF 保護的 OAuth URL
66+
const state = login.generateState();
67+
const authUrl = generateAuthUrl({
68+
channelId: "YOUR_CHANNEL_ID",
69+
redirectUri: "https://example.com/callback",
70+
state,
71+
});
72+
73+
// 回調後驗證 state 並交換 token
74+
if (login.validateState(savedState, returnedState)) {
75+
const tokens = await issueAccessToken(channelId, channelSecret, code, redirectUri);
76+
const user = await login.verify(tokens.id_token, channelId);
77+
console.log(user.name, user.email);
78+
}
6779
```
6880

6981
## 套件列表
7082

71-
- **@linekit/core**: Webhook 驗證、Context、Router。
72-
- **@linekit/messaging**: Messaging API 客戶端 (Reply, Push, Multicast)
73-
- **@linekit/login**: OAuth ID Token 驗證。
83+
- **@linekit/core**: Webhook 驗證(時序安全)、Context、Router 含錯誤處理
84+
- **@linekit/messaging**: Messaging API 客戶端 (Reply, Push, Multicast, Rich Menu) 含輸入驗證
85+
- **@linekit/login**: OAuth 流程、ID Token 驗證、CSRF state 工具
7486
- **@linekit/express**: Express.js 的適配器。
7587

7688
## 文件

packages/core/src/context.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
import {
2-
replyText,
32
replyMessage,
4-
pushText,
53
pushMessage,
64
Message,
75
} from "@linekit/messaging";
8-
import { WebhookEvent, LineAppConfig } from "./types.js";
6+
import { WebhookEvent, LineAppConfig, isReplyableEvent } from "./types.js";
97

108
export class Context {
119
public readonly event: WebhookEvent;
@@ -21,7 +19,7 @@ export class Context {
2119
}
2220

2321
get replyToken(): string | undefined {
24-
return (this.event as any).replyToken;
22+
return isReplyableEvent(this.event) ? this.event.replyToken : undefined;
2523
}
2624

2725
public async reply(messages: Message[] | Message) {

packages/core/src/router.ts

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,34 @@ import { Context } from "./context.js";
22
import { LineAppConfig, WebhookEvent } from "./types.js";
33

44
type EventHandler = (ctx: Context) => Promise<void> | void;
5+
type ErrorHandler = (error: Error, ctx: Context) => Promise<void> | void;
56

67
export interface RouterHandlers {
78
[key: string]: EventHandler;
89
}
910

10-
export function createRouter(config: LineAppConfig, handlers: RouterHandlers) {
11-
return async (req: any, res: any) => {
11+
export interface RouterOptions {
12+
onError?: ErrorHandler;
13+
}
14+
15+
// Minimal request/response interfaces for framework compatibility
16+
interface WebhookRequest {
17+
body?: {
18+
events?: WebhookEvent[];
19+
};
20+
}
21+
22+
interface WebhookResponse {
23+
status?: (code: number) => WebhookResponse;
24+
end?: () => void;
25+
}
26+
27+
export function createRouter(
28+
config: LineAppConfig,
29+
handlers: RouterHandlers,
30+
options: RouterOptions = {}
31+
) {
32+
return async (req: WebhookRequest, res?: WebhookResponse) => {
1233
// Expect req.body.events to be present (handled by webhook middleware)
1334
const events: WebhookEvent[] = req.body?.events || [];
1435

@@ -20,18 +41,24 @@ export function createRouter(config: LineAppConfig, handlers: RouterHandlers) {
2041
try {
2142
await handler(ctx);
2243
} catch (err) {
23-
console.error(`Error handling event ${event.type}:`, err);
24-
// We generally absorb errors in handlers to avoid crashing the loop,
25-
// but strictly speaking we might want to let it bubble if the user wants.
26-
// For a framework, logging and continuing is usually safer for webhooks.
44+
const error = err instanceof Error ? err : new Error(String(err));
45+
if (options.onError) {
46+
try {
47+
await options.onError(error, ctx);
48+
} catch (onErrorErr) {
49+
console.error(`Error in onError handler:`, onErrorErr);
50+
}
51+
} else {
52+
console.error(`Error handling event ${event.type}:`, error);
53+
}
2754
}
2855
}
2956
})
3057
);
3158

3259
// If res is provided (Express-like), send 200
3360
if (res && typeof res.status === "function") {
34-
res.status(200).end();
61+
res.status(200).end?.();
3562
}
3663
};
3764
}

packages/core/src/types.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,11 +133,20 @@ export interface PostbackEvent extends EventBase {
133133
replyToken: string;
134134
postback: {
135135
data: string;
136-
params?: Record<string, any>;
136+
params?: Record<string, string>;
137137
};
138138
}
139139

140140
export interface UnknownEvent extends EventBase {
141141
type: string;
142-
[key: string]: any;
142+
replyToken?: string;
143+
[key: string]: unknown;
144+
}
145+
146+
// Helper type for events that can be replied to
147+
export type ReplyableEvent = MessageEvent | FollowEvent | PostbackEvent;
148+
149+
// Type guard for replyable events
150+
export function isReplyableEvent(event: WebhookEvent): event is ReplyableEvent {
151+
return "replyToken" in event && typeof (event as ReplyableEvent).replyToken === "string";
143152
}

packages/core/src/webhook.ts

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { IncomingMessage, ServerResponse } from "http";
2-
import * as crypto from "crypto";
2+
import { createHmac, timingSafeEqual } from "crypto";
33
import { SignatureValidationFailedError, JSONParseError } from "./errors.js";
44

55
interface WebhookConfig {
@@ -47,31 +47,25 @@ export function createWebhookMiddleware(config: WebhookConfig) {
4747
}
4848

4949
if (!body) {
50-
// Fallback strategy: strictly speaking we need the raw string bytes for signature.
51-
// If the framework already parsed it to JSON and discarded raw bytes, we can't strictly verify signature
52-
// without reconstruction (which is flaky).
53-
// For now, we assume rawBody string is available or we just read it.
54-
// If body is missing, we can't verify.
55-
if (req.body && typeof req.body === 'object') {
56-
// Dangerous fallback: stringify. Only works if keys are ordered same way.
57-
// Ideally we throw or warn.
58-
console.warn("linekit: rawBody not found, skipping signature verification is NOT RECOMMENDED. Please ensure raw body is available.");
59-
return next?.();
60-
}
61-
throw new Error("Missing request body for signature verification");
50+
// rawBody is required for signature verification
51+
// If the framework already parsed it to JSON and discarded raw bytes, we cannot verify
52+
throw new SignatureValidationFailedError("Cannot verify signature: rawBody not available. Please configure your framework to preserve raw body.");
6253
}
6354

6455
if (!signature) {
6556
throw new SignatureValidationFailedError("Missing X-Line-Signature header");
6657
}
6758

68-
const hash = crypto
69-
.createHmac("sha256", config.channelSecret)
59+
const hash = createHmac("sha256", config.channelSecret)
7060
.update(body)
7161
.digest("base64");
7262

73-
if (hash !== signature) {
74-
// use safe constant time comparison if possible, but string check is standard in many examples.
63+
// Use timing-safe comparison to prevent timing attacks
64+
const hashBuffer = Buffer.from(hash);
65+
const signatureBuffer = Buffer.from(signature);
66+
67+
if (hashBuffer.length !== signatureBuffer.length ||
68+
!timingSafeEqual(hashBuffer, signatureBuffer)) {
7569
throw new SignatureValidationFailedError();
7670
}
7771

packages/login/src/errors.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export class LineLoginError extends Error {
2+
public code?: string;
3+
public status?: number;
4+
5+
constructor(message: string, code?: string, status?: number) {
6+
super(message);
7+
this.name = "LineLoginError";
8+
this.code = code;
9+
this.status = status;
10+
}
11+
}

packages/login/src/index.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
export * from "./verify.js";
22
export * from "./oauth.js";
3+
export * from "./errors.js";
4+
export * from "./state.js";
35

4-
// Facade for convenience (as requested by user)
6+
// Facade for convenience
57
import { verifyIdToken } from "./verify.js";
8+
import { generateState, validateState, generateNonce } from "./state.js";
9+
610
export const login = {
7-
verify: verifyIdToken
11+
verify: verifyIdToken,
12+
generateState,
13+
validateState,
14+
generateNonce,
815
};

packages/login/src/oauth.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { LineLoginError } from "./errors.js";
2+
13
export interface GenerateAuthUrlOptions {
24
channelId: string;
35
redirectUri: string;
@@ -8,6 +10,16 @@ export interface GenerateAuthUrlOptions {
810
}
911

1012
export function generateAuthUrl(options: GenerateAuthUrlOptions): string {
13+
if (!options.channelId) {
14+
throw new LineLoginError("channelId is required", "INVALID_PARAMETER");
15+
}
16+
if (!options.redirectUri) {
17+
throw new LineLoginError("redirectUri is required", "INVALID_PARAMETER");
18+
}
19+
if (!options.state) {
20+
throw new LineLoginError("state is required for CSRF protection", "INVALID_PARAMETER");
21+
}
22+
1123
const params = new URLSearchParams();
1224
params.append('response_type', 'code');
1325
params.append('client_id', options.channelId);
@@ -36,6 +48,19 @@ export async function issueAccessToken(
3648
code: string,
3749
redirectUri: string
3850
): Promise<IssueAccessTokenResponse> {
51+
if (!channelId) {
52+
throw new LineLoginError("channelId is required", "INVALID_PARAMETER");
53+
}
54+
if (!channelSecret) {
55+
throw new LineLoginError("channelSecret is required", "INVALID_PARAMETER");
56+
}
57+
if (!code) {
58+
throw new LineLoginError("authorization code is required", "INVALID_PARAMETER");
59+
}
60+
if (!redirectUri) {
61+
throw new LineLoginError("redirectUri is required", "INVALID_PARAMETER");
62+
}
63+
3964
const params = new URLSearchParams();
4065
params.append('grant_type', 'authorization_code');
4166
params.append('code', code);
@@ -52,8 +77,12 @@ export async function issueAccessToken(
5277
});
5378

5479
if (!res.ok) {
55-
const err = await res.text();
56-
throw new Error(`Failed to issue access token: ${res.statusText} ${err}`);
80+
const errorText = await res.text();
81+
throw new LineLoginError(
82+
`Failed to issue access token: ${res.statusText}`,
83+
"TOKEN_ISSUE_FAILED",
84+
res.status
85+
);
5786
}
5887

5988
return res.json();

packages/login/src/state.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { randomBytes } from "crypto";
2+
3+
/**
4+
* Generate a cryptographically secure random state string for OAuth CSRF protection.
5+
* @param length - Length of the random bytes (default: 32, resulting in 64 hex characters)
6+
* @returns A random hex string
7+
*/
8+
export function generateState(length: number = 32): string {
9+
return randomBytes(length).toString("hex");
10+
}
11+
12+
/**
13+
* Validate that the returned state matches the expected state.
14+
* Uses timing-safe comparison to prevent timing attacks.
15+
* @param expected - The state that was sent in the authorization request
16+
* @param actual - The state returned from the authorization server
17+
* @returns true if states match, false otherwise
18+
*/
19+
export function validateState(expected: string, actual: string): boolean {
20+
if (!expected || !actual) {
21+
return false;
22+
}
23+
if (expected.length !== actual.length) {
24+
return false;
25+
}
26+
// Simple timing-safe comparison for strings
27+
let result = 0;
28+
for (let i = 0; i < expected.length; i++) {
29+
result |= expected.charCodeAt(i) ^ actual.charCodeAt(i);
30+
}
31+
return result === 0;
32+
}
33+
34+
/**
35+
* Generate a cryptographically secure nonce for ID token validation.
36+
* @param length - Length of the random bytes (default: 32)
37+
* @returns A random hex string
38+
*/
39+
export function generateNonce(length: number = 32): string {
40+
return randomBytes(length).toString("hex");
41+
}

0 commit comments

Comments
 (0)