forked from CopilotKit/CopilotKit
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathauth.ts
More file actions
113 lines (105 loc) · 4.44 KB
/
Copy pathauth.ts
File metadata and controls
113 lines (105 loc) · 4.44 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
import crypto from "node:crypto";
import type { MiddlewareHandler } from "hono";
/**
* Thrown at construction time when no expected token is available.
*
* This is intentionally fail-loud: a misconfigured deployment that silently
* defaulted to "always allow" is the failure mode this gate exists to
* prevent. Operators must see the error during boot, not after the trigger
* endpoint starts accepting unauthenticated requests in production.
*/
export class MissingAuthTokenError extends Error {
constructor(envVar: string) {
super(
`bearerAuth: no expected token configured. Set ${envVar} or pass an explicit expectedToken option.`,
);
this.name = "MissingAuthTokenError";
}
}
/**
* Constant-time string comparison.
*
* Wraps `crypto.timingSafeEqual` with two operator-friendly behaviors that
* the raw primitive lacks:
*
* 1. Length-mismatch returns `false` instead of throwing. The naked
* `timingSafeEqual` requires equal-length buffers; throwing on
* mismatch leaks length via timing AND surfaces as a 500 to the
* caller. We swallow the mismatch and report a plain auth failure.
* 2. Empty strings always return `false`. An empty token is never a
* valid credential — we refuse to even attempt the compare so a
* construction bug that produces "" can't masquerade as a valid
* match.
*
* Exported for direct unit-test coverage; the regex on
* `crypto.timingSafeEqual` is non-trivial to assert on indirectly.
*/
export function constantTimeEqual(a: string, b: string): boolean {
if (a.length === 0 || b.length === 0) return false;
const bufA = Buffer.from(a, "utf8");
const bufB = Buffer.from(b, "utf8");
if (bufA.length !== bufB.length) return false;
return crypto.timingSafeEqual(bufA, bufB);
}
export interface BearerAuthOptions {
/**
* Explicit expected token. When provided, takes priority over the env
* lookup. Useful for tests and for callers that load the token from a
* non-env source (e.g. a secrets manager).
*/
expectedToken?: string;
/**
* Name of the env var to read at construction time when `expectedToken`
* is not provided. Defaults to `OPS_TRIGGER_TOKEN`.
*/
envVar?: string;
}
const DEFAULT_ENV_VAR = "OPS_TRIGGER_TOKEN";
/**
* Construct a Hono bearer-auth middleware.
*
* Rejects with 401 + `{error: "unauthorized"}` JSON when:
* - the Authorization header is absent
* - the header is present but does not start with `Bearer ` (case-insensitive scheme per RFC 6750 §2.1)
* - the bearer token does not match the expected token (constant-time compare)
*
* Calls `next()` on a successful match.
*
* Construction is fail-loud: if no `expectedToken` is supplied AND the
* env var is unset/empty, this function throws `MissingAuthTokenError`
* during boot. Defaulting to "always reject" or "always allow" would both
* be footguns — a misconfigured deployment must surface during init, not
* silently degrade once a trigger endpoint is hit.
*/
export function bearerAuth(opts: BearerAuthOptions = {}): MiddlewareHandler {
const envVar = opts.envVar ?? DEFAULT_ENV_VAR;
const rawExpected =
opts.expectedToken !== undefined ? opts.expectedToken : process.env[envVar];
// R3-A.4: trim BOTH sides at construction so the comparison is symmetric
// with the trimmed presented token below. Pre-fix, an OPS_TRIGGER_TOKEN
// env var copy-pasted with surrounding whitespace would silently 401
// every well-formed Bearer request — the presented side was trimmed but
// the expected side held the leading/trailing spaces verbatim. Trim
// here normalises both sides; an all-whitespace expected token is then
// caught by the empty-string fail-loud guard below.
const expectedToken = rawExpected?.trim() ?? "";
if (!expectedToken) {
// Fail-loud: empty string, undefined, AND whitespace-only are all
// treated as misconfig (whitespace-only collapses to "" after trim).
throw new MissingAuthTokenError(envVar);
}
return async (c, next) => {
const header = c.req.header("Authorization") ?? "";
// RFC 6750 §2.1: the auth-scheme is case-insensitive. Be lenient on
// "Bearer "/"bearer " etc., strict on the token portion.
const match = /^Bearer\s+(.+)$/i.exec(header);
if (!match) {
return c.json({ error: "unauthorized" }, 401);
}
const presented = match[1].trim();
if (!constantTimeEqual(presented, expectedToken)) {
return c.json({ error: "unauthorized" }, 401);
}
await next();
};
}