forked from CopilotKit/CopilotKit
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathauth.test.ts
More file actions
207 lines (183 loc) · 7.47 KB
/
Copy pathauth.test.ts
File metadata and controls
207 lines (183 loc) · 7.47 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
import { Hono } from "hono";
import { describe, it, expect, afterEach } from "vitest";
import {
bearerAuth,
MissingAuthTokenError,
constantTimeEqual,
} from "./auth.js";
const ENV_VAR = "OPS_TRIGGER_TOKEN_TEST";
function makeApp(token: string): Hono {
const app = new Hono();
app.use("/protected/*", bearerAuth({ expectedToken: token }));
app.post("/protected/trigger", (c) => c.json({ ok: true }));
return app;
}
describe("constantTimeEqual", () => {
it("returns true for identical strings", () => {
expect(constantTimeEqual("abc123", "abc123")).toBe(true);
});
it("returns false for different strings of equal length", () => {
expect(constantTimeEqual("abcdef", "abcdeg")).toBe(false);
});
it("returns false for strings of differing length without throwing", () => {
// Length mismatch must NOT throw — that would leak length via timing.
// Returning false is the correct constant-time-safe behavior.
expect(constantTimeEqual("a", "abc")).toBe(false);
expect(constantTimeEqual("abc", "a")).toBe(false);
});
it("returns false when either input is empty", () => {
expect(constantTimeEqual("", "x")).toBe(false);
expect(constantTimeEqual("x", "")).toBe(false);
// Two empty strings: defensively false — empty is never a valid token.
expect(constantTimeEqual("", "")).toBe(false);
});
});
describe("bearerAuth construction", () => {
afterEach(() => {
delete process.env[ENV_VAR];
delete process.env.OPS_TRIGGER_TOKEN;
});
it("throws MissingAuthTokenError when env var is unset and no expectedToken given", () => {
delete process.env[ENV_VAR];
expect(() => bearerAuth({ envVar: ENV_VAR })).toThrow(
MissingAuthTokenError,
);
});
it("throws MissingAuthTokenError when env var is empty string and no expectedToken given", () => {
process.env[ENV_VAR] = "";
expect(() => bearerAuth({ envVar: ENV_VAR })).toThrow(
MissingAuthTokenError,
);
});
it("does not throw when explicit expectedToken provided (env var unset)", () => {
delete process.env[ENV_VAR];
expect(() =>
bearerAuth({ expectedToken: "explicit-tok", envVar: ENV_VAR }),
).not.toThrow();
});
it("explicit expectedToken takes priority over env lookup", () => {
process.env[ENV_VAR] = "env-token";
const mw = bearerAuth({ expectedToken: "explicit-tok", envVar: ENV_VAR });
expect(mw).toBeTypeOf("function");
});
it("defaults to OPS_TRIGGER_TOKEN env var when envVar option not provided", () => {
delete process.env.OPS_TRIGGER_TOKEN;
expect(() => bearerAuth()).toThrow(MissingAuthTokenError);
process.env.OPS_TRIGGER_TOKEN = "from-default-env";
expect(() => bearerAuth()).not.toThrow();
});
});
describe("bearerAuth middleware", () => {
const TOKEN = "s3cr3t-trigger-token";
it("returns 401 with JSON {error: 'unauthorized'} when Authorization header is missing", async () => {
const app = makeApp(TOKEN);
const res = await app.request("/protected/trigger", { method: "POST" });
expect(res.status).toBe(401);
const body = await res.json();
expect(body).toEqual({ error: "unauthorized" });
});
it("returns 401 when Authorization header lacks the Bearer prefix", async () => {
const app = makeApp(TOKEN);
const res = await app.request("/protected/trigger", {
method: "POST",
headers: { Authorization: TOKEN }, // no "Bearer "
});
expect(res.status).toBe(401);
const body = await res.json();
expect(body).toEqual({ error: "unauthorized" });
});
it("returns 401 when Authorization header uses a non-Bearer scheme (Basic, Token, etc.)", async () => {
const app = makeApp(TOKEN);
const res = await app.request("/protected/trigger", {
method: "POST",
headers: { Authorization: `Token ${TOKEN}` },
});
expect(res.status).toBe(401);
});
it("returns 401 when Bearer prefix is present but token is wrong", async () => {
const app = makeApp(TOKEN);
const res = await app.request("/protected/trigger", {
method: "POST",
headers: { Authorization: "Bearer wrong-token" },
});
expect(res.status).toBe(401);
const body = await res.json();
expect(body).toEqual({ error: "unauthorized" });
});
it("returns 401 when Bearer is present but token portion is empty", async () => {
const app = makeApp(TOKEN);
const res = await app.request("/protected/trigger", {
method: "POST",
headers: { Authorization: "Bearer " },
});
expect(res.status).toBe(401);
});
it("calls next() when token matches — handler runs and returns 200", async () => {
const app = makeApp(TOKEN);
const res = await app.request("/protected/trigger", {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}` },
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual({ ok: true });
});
it("is case-sensitive on the token (constant-time-safe compare)", async () => {
const app = makeApp(TOKEN);
const res = await app.request("/protected/trigger", {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN.toUpperCase()}` },
});
expect(res.status).toBe(401);
});
it("rejects a token of different length without throwing", async () => {
// Constant-time compare must handle length mismatch gracefully —
// a throw here would leak length via timing AND surface as a 500.
const app = makeApp(TOKEN);
const res = await app.request("/protected/trigger", {
method: "POST",
headers: { Authorization: "Bearer x" },
});
expect(res.status).toBe(401);
});
it("accepts the Bearer scheme case-insensitively (RFC 6750)", async () => {
// RFC 6750 §2.1: "Bearer" auth-scheme is case-insensitive. Be lenient
// on the scheme match; strict only on the token.
const app = makeApp(TOKEN);
const res = await app.request("/protected/trigger", {
method: "POST",
headers: { Authorization: `bearer ${TOKEN}` },
});
expect(res.status).toBe(200);
});
// R3-A.4: presented-token whitespace was trimmed but expected token was
// compared verbatim. An OPS_TRIGGER_TOKEN env var that was copy-pasted
// with surrounding whitespace would silently 401 every well-formed
// request. Trim BOTH sides at construction so the comparison is symmetric.
it("R3-A.4: accepts a Bearer token when expectedToken has leading/trailing whitespace", async () => {
// expectedToken padded with spaces — common env-var copy-paste mistake.
const padded = " abc ";
const app = new Hono();
app.use("/protected/*", bearerAuth({ expectedToken: padded }));
app.post("/protected/trigger", (c) => c.json({ ok: true }));
const res = await app.request("/protected/trigger", {
method: "POST",
headers: { Authorization: "Bearer abc" },
});
expect(res.status).toBe(200);
});
it("R3-A.4: accepts a Bearer token presented with extra whitespace when expected token is clean", async () => {
// Clean expectedToken; presented header has extra whitespace around the
// token portion. The regex captures anything after `Bearer\s+` up to the
// header end so trailing spaces show up in the captured group; .trim()
// normalises them.
const app = new Hono();
app.use("/protected/*", bearerAuth({ expectedToken: "abc" }));
app.post("/protected/trigger", (c) => c.json({ ok: true }));
const res = await app.request("/protected/trigger", {
method: "POST",
headers: { Authorization: "Bearer abc " },
});
expect(res.status).toBe(200);
});
});