harper-knowledge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +276 -0
- package/config.yaml +17 -0
- package/dist/core/embeddings.d.ts +29 -0
- package/dist/core/embeddings.js +199 -0
- package/dist/core/entries.d.ts +85 -0
- package/dist/core/entries.js +235 -0
- package/dist/core/history.d.ts +30 -0
- package/dist/core/history.js +119 -0
- package/dist/core/search.d.ts +23 -0
- package/dist/core/search.js +306 -0
- package/dist/core/tags.d.ts +32 -0
- package/dist/core/tags.js +76 -0
- package/dist/core/triage.d.ts +55 -0
- package/dist/core/triage.js +126 -0
- package/dist/http-utils.d.ts +37 -0
- package/dist/http-utils.js +132 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +76 -0
- package/dist/mcp/server.d.ts +24 -0
- package/dist/mcp/server.js +124 -0
- package/dist/mcp/tools.d.ts +13 -0
- package/dist/mcp/tools.js +497 -0
- package/dist/oauth/authorize.d.ts +27 -0
- package/dist/oauth/authorize.js +438 -0
- package/dist/oauth/github.d.ts +28 -0
- package/dist/oauth/github.js +62 -0
- package/dist/oauth/keys.d.ts +33 -0
- package/dist/oauth/keys.js +100 -0
- package/dist/oauth/metadata.d.ts +21 -0
- package/dist/oauth/metadata.js +55 -0
- package/dist/oauth/middleware.d.ts +22 -0
- package/dist/oauth/middleware.js +64 -0
- package/dist/oauth/register.d.ts +14 -0
- package/dist/oauth/register.js +83 -0
- package/dist/oauth/token.d.ts +15 -0
- package/dist/oauth/token.js +178 -0
- package/dist/oauth/validate.d.ts +30 -0
- package/dist/oauth/validate.js +52 -0
- package/dist/resources/HistoryResource.d.ts +38 -0
- package/dist/resources/HistoryResource.js +38 -0
- package/dist/resources/KnowledgeEntryResource.d.ts +64 -0
- package/dist/resources/KnowledgeEntryResource.js +157 -0
- package/dist/resources/QueryLogResource.d.ts +20 -0
- package/dist/resources/QueryLogResource.js +57 -0
- package/dist/resources/ServiceKeyResource.d.ts +51 -0
- package/dist/resources/ServiceKeyResource.js +132 -0
- package/dist/resources/TagResource.d.ts +25 -0
- package/dist/resources/TagResource.js +32 -0
- package/dist/resources/TriageResource.d.ts +51 -0
- package/dist/resources/TriageResource.js +107 -0
- package/dist/types.d.ts +317 -0
- package/dist/types.js +7 -0
- package/dist/webhooks/datadog.d.ts +26 -0
- package/dist/webhooks/datadog.js +120 -0
- package/dist/webhooks/github.d.ts +24 -0
- package/dist/webhooks/github.js +167 -0
- package/dist/webhooks/middleware.d.ts +14 -0
- package/dist/webhooks/middleware.js +161 -0
- package/dist/webhooks/types.d.ts +17 -0
- package/dist/webhooks/types.js +4 -0
- package/package.json +72 -0
- package/schema/knowledge.graphql +134 -0
- package/web/index.html +735 -0
- package/web/js/app.js +461 -0
- package/web/js/detail.js +223 -0
- package/web/js/editor.js +303 -0
- package/web/js/search.js +238 -0
- package/web/js/triage.js +305 -0
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth Authorization Endpoint
|
|
3
|
+
*
|
|
4
|
+
* GET /oauth/authorize — MCP OAuth 2.1 authorization endpoint.
|
|
5
|
+
*
|
|
6
|
+
* Shows a login page with GitHub as the primary auth method and a
|
|
7
|
+
* subtle link to fall back to Harper credentials. If the user has an
|
|
8
|
+
* active session (from a prior GitHub login), issues an auth code
|
|
9
|
+
* immediately.
|
|
10
|
+
*
|
|
11
|
+
* Org membership is checked against the ALLOWED_GITHUB_ORGS env var
|
|
12
|
+
* for GitHub logins. Harper credential logins bypass org checks.
|
|
13
|
+
*/
|
|
14
|
+
import crypto from "node:crypto";
|
|
15
|
+
import { readBody, parseFormBody } from "../http-utils.js";
|
|
16
|
+
import { checkOrgMembership } from "./github.js";
|
|
17
|
+
/**
|
|
18
|
+
* Handle GET /oauth/authorize
|
|
19
|
+
*
|
|
20
|
+
* Three modes:
|
|
21
|
+
* 1. Returning from GitHub login (`pending` param) — complete authorization.
|
|
22
|
+
* 2. User already has a session — issue auth code directly.
|
|
23
|
+
* 3. First visit — show login page with GitHub button + Harper credentials.
|
|
24
|
+
*/
|
|
25
|
+
export async function handleAuthorizeGet(request) {
|
|
26
|
+
const url = new URL(request.url || request.pathname || "/", `http://${request.host || "localhost"}`);
|
|
27
|
+
const pendingId = url.searchParams.get("pending");
|
|
28
|
+
// Returning from GitHub login — complete the authorization
|
|
29
|
+
if (pendingId) {
|
|
30
|
+
return handleAuthorizeComplete(request, pendingId);
|
|
31
|
+
}
|
|
32
|
+
// First visit — extract and validate OAuth params
|
|
33
|
+
const params = extractParams(url.searchParams);
|
|
34
|
+
const validation = await validateAuthorizeParams(params);
|
|
35
|
+
if (validation.error) {
|
|
36
|
+
return errorPage(validation.error);
|
|
37
|
+
}
|
|
38
|
+
// If user already has a session from @harperfast/oauth, issue code directly
|
|
39
|
+
const session = request.session;
|
|
40
|
+
if (session?.user && session?.oauth?.accessToken) {
|
|
41
|
+
return issueAuthCodeGitHub(request, params, session);
|
|
42
|
+
}
|
|
43
|
+
// Show login page
|
|
44
|
+
return loginPage(params);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Handle POST /oauth/authorize — Harper credential login.
|
|
48
|
+
*/
|
|
49
|
+
export async function handleAuthorizePost(request) {
|
|
50
|
+
let form;
|
|
51
|
+
try {
|
|
52
|
+
const rawBody = await readBody(request);
|
|
53
|
+
form = parseFormBody(rawBody);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return errorPage("Invalid request body");
|
|
57
|
+
}
|
|
58
|
+
const username = form.username || "";
|
|
59
|
+
const password = form.password || "";
|
|
60
|
+
// Reconstruct the OAuth params from the hidden form fields
|
|
61
|
+
const params = {
|
|
62
|
+
client_id: form.client_id || "",
|
|
63
|
+
redirect_uri: form.redirect_uri || "",
|
|
64
|
+
response_type: form.response_type || "code",
|
|
65
|
+
state: form.state || "",
|
|
66
|
+
code_challenge: form.code_challenge || "",
|
|
67
|
+
code_challenge_method: form.code_challenge_method || "S256",
|
|
68
|
+
scope: form.scope || "mcp:read mcp:write",
|
|
69
|
+
};
|
|
70
|
+
// Re-validate OAuth params
|
|
71
|
+
const validation = await validateAuthorizeParams(params);
|
|
72
|
+
if (validation.error) {
|
|
73
|
+
return errorPage(validation.error);
|
|
74
|
+
}
|
|
75
|
+
if (!username || !password) {
|
|
76
|
+
return loginPage(params, "Username and password are required.");
|
|
77
|
+
}
|
|
78
|
+
// Validate credentials against Harper Operations API
|
|
79
|
+
const valid = await validateHarperCredentials(username, password);
|
|
80
|
+
if (!valid) {
|
|
81
|
+
return loginPage(params, "Invalid username or password.");
|
|
82
|
+
}
|
|
83
|
+
// Issue authorization code for Harper user
|
|
84
|
+
const code = crypto.randomUUID();
|
|
85
|
+
await databases.kb.OAuthCode.put({
|
|
86
|
+
id: code,
|
|
87
|
+
clientId: params.client_id,
|
|
88
|
+
userId: `harper:${username}`,
|
|
89
|
+
scope: params.scope,
|
|
90
|
+
codeChallenge: params.code_challenge,
|
|
91
|
+
codeChallengeMethod: params.code_challenge_method,
|
|
92
|
+
redirectUri: params.redirect_uri,
|
|
93
|
+
type: "code",
|
|
94
|
+
});
|
|
95
|
+
const redirectUrl = new URL(params.redirect_uri);
|
|
96
|
+
redirectUrl.searchParams.set("code", code);
|
|
97
|
+
redirectUrl.searchParams.set("state", params.state);
|
|
98
|
+
logger?.info?.(`OAuth code issued for Harper user ${username}, client ${params.client_id}`);
|
|
99
|
+
return new Response(null, {
|
|
100
|
+
status: 302,
|
|
101
|
+
headers: { Location: redirectUrl.toString() },
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Complete authorization after the user returns from GitHub login.
|
|
106
|
+
*/
|
|
107
|
+
async function handleAuthorizeComplete(request, pendingId) {
|
|
108
|
+
const session = request.session;
|
|
109
|
+
if (!session?.user) {
|
|
110
|
+
return errorPage("Authentication required. Please try again.");
|
|
111
|
+
}
|
|
112
|
+
// Look up the stored OAuth params
|
|
113
|
+
const pending = await databases.kb.OAuthCode.get(pendingId);
|
|
114
|
+
if (!pending || pending.type !== "pending") {
|
|
115
|
+
return errorPage("Authorization session expired. Please try again.");
|
|
116
|
+
}
|
|
117
|
+
// Delete the pending record (one-time use)
|
|
118
|
+
await databases.kb.OAuthCode.delete(pendingId);
|
|
119
|
+
// Check org membership using the GitHub access token from the session
|
|
120
|
+
const accessToken = session?.oauth?.accessToken;
|
|
121
|
+
if (!accessToken) {
|
|
122
|
+
return errorPage("No GitHub access token in session. Please try logging in again.");
|
|
123
|
+
}
|
|
124
|
+
const authorized = await checkOrgMembership(accessToken);
|
|
125
|
+
if (!authorized) {
|
|
126
|
+
const username = session.oauthUser?.username || session.user;
|
|
127
|
+
logger?.warn?.(`OAuth denied: user ${username} not in allowed orgs`);
|
|
128
|
+
return errorPage(`User ${username} is not a member of an authorized GitHub organization.`);
|
|
129
|
+
}
|
|
130
|
+
// Issue authorization code
|
|
131
|
+
const code = crypto.randomUUID();
|
|
132
|
+
const mcpClientState = pending.userId;
|
|
133
|
+
const username = session.oauthUser?.username || session.user;
|
|
134
|
+
await databases.kb.OAuthCode.put({
|
|
135
|
+
id: code,
|
|
136
|
+
clientId: pending.clientId,
|
|
137
|
+
userId: `github:${username}`,
|
|
138
|
+
scope: pending.scope,
|
|
139
|
+
codeChallenge: pending.codeChallenge,
|
|
140
|
+
codeChallengeMethod: pending.codeChallengeMethod,
|
|
141
|
+
redirectUri: pending.redirectUri,
|
|
142
|
+
type: "code",
|
|
143
|
+
});
|
|
144
|
+
const redirectUrl = new URL(pending.redirectUri);
|
|
145
|
+
redirectUrl.searchParams.set("code", code);
|
|
146
|
+
redirectUrl.searchParams.set("state", mcpClientState);
|
|
147
|
+
logger?.info?.(`OAuth code issued for user ${username}, client ${pending.clientId}`);
|
|
148
|
+
return new Response(null, {
|
|
149
|
+
status: 302,
|
|
150
|
+
headers: { Location: redirectUrl.toString() },
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Issue an auth code immediately (user already has a GitHub session).
|
|
155
|
+
*/
|
|
156
|
+
async function issueAuthCodeGitHub(_request, params, session) {
|
|
157
|
+
const accessToken = session.oauth?.accessToken;
|
|
158
|
+
if (!accessToken) {
|
|
159
|
+
return errorPage("No GitHub access token in session. Please try logging in again.");
|
|
160
|
+
}
|
|
161
|
+
const authorized = await checkOrgMembership(accessToken);
|
|
162
|
+
if (!authorized) {
|
|
163
|
+
const username = session.oauthUser?.username || session.user;
|
|
164
|
+
logger?.warn?.(`OAuth denied: user ${username} not in allowed orgs`);
|
|
165
|
+
return errorPage(`User ${username} is not a member of an authorized GitHub organization.`);
|
|
166
|
+
}
|
|
167
|
+
const code = crypto.randomUUID();
|
|
168
|
+
const username = session.oauthUser?.username || session.user;
|
|
169
|
+
await databases.kb.OAuthCode.put({
|
|
170
|
+
id: code,
|
|
171
|
+
clientId: params.client_id,
|
|
172
|
+
userId: `github:${username}`,
|
|
173
|
+
scope: params.scope,
|
|
174
|
+
codeChallenge: params.code_challenge,
|
|
175
|
+
codeChallengeMethod: params.code_challenge_method,
|
|
176
|
+
redirectUri: params.redirect_uri,
|
|
177
|
+
type: "code",
|
|
178
|
+
});
|
|
179
|
+
const redirectUrl = new URL(params.redirect_uri);
|
|
180
|
+
redirectUrl.searchParams.set("code", code);
|
|
181
|
+
redirectUrl.searchParams.set("state", params.state);
|
|
182
|
+
logger?.info?.(`OAuth code issued for user ${username}, client ${params.client_id}`);
|
|
183
|
+
return new Response(null, {
|
|
184
|
+
status: 302,
|
|
185
|
+
headers: { Location: redirectUrl.toString() },
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Validate credentials against Harper's Operations API (localhost:9925).
|
|
190
|
+
*/
|
|
191
|
+
async function validateHarperCredentials(username, password) {
|
|
192
|
+
try {
|
|
193
|
+
const response = await fetch("http://localhost:9925/", {
|
|
194
|
+
method: "POST",
|
|
195
|
+
headers: {
|
|
196
|
+
"Content-Type": "application/json",
|
|
197
|
+
Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`,
|
|
198
|
+
},
|
|
199
|
+
body: JSON.stringify({ operation: "user_info" }),
|
|
200
|
+
});
|
|
201
|
+
return response.ok;
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
logger?.error?.("Harper credential validation failed:", error.message);
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
function extractParams(searchParams) {
|
|
209
|
+
return {
|
|
210
|
+
client_id: searchParams.get("client_id") || "",
|
|
211
|
+
redirect_uri: searchParams.get("redirect_uri") || "",
|
|
212
|
+
response_type: searchParams.get("response_type") || "",
|
|
213
|
+
state: searchParams.get("state") || "",
|
|
214
|
+
code_challenge: searchParams.get("code_challenge") || "",
|
|
215
|
+
code_challenge_method: searchParams.get("code_challenge_method") || "",
|
|
216
|
+
scope: searchParams.get("scope") || "mcp:read mcp:write",
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
async function validateAuthorizeParams(params) {
|
|
220
|
+
if (!params.client_id) {
|
|
221
|
+
return { error: "Missing required parameter: client_id" };
|
|
222
|
+
}
|
|
223
|
+
if (!params.redirect_uri) {
|
|
224
|
+
return { error: "Missing required parameter: redirect_uri" };
|
|
225
|
+
}
|
|
226
|
+
if (params.response_type !== "code") {
|
|
227
|
+
return { error: 'response_type must be "code"' };
|
|
228
|
+
}
|
|
229
|
+
if (!params.state) {
|
|
230
|
+
return { error: "Missing required parameter: state" };
|
|
231
|
+
}
|
|
232
|
+
if (!params.code_challenge) {
|
|
233
|
+
return {
|
|
234
|
+
error: "Missing required parameter: code_challenge (PKCE is required)",
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
if (params.code_challenge_method !== "S256") {
|
|
238
|
+
return { error: 'code_challenge_method must be "S256"' };
|
|
239
|
+
}
|
|
240
|
+
// Validate client exists
|
|
241
|
+
const client = await databases.kb.OAuthClient.get(params.client_id);
|
|
242
|
+
if (!client) {
|
|
243
|
+
return { error: "Unknown client_id" };
|
|
244
|
+
}
|
|
245
|
+
// Validate redirect_uri matches registration (exact match)
|
|
246
|
+
const registeredUris = client.redirectUris;
|
|
247
|
+
if (!registeredUris || !registeredUris.includes(params.redirect_uri)) {
|
|
248
|
+
return {
|
|
249
|
+
error: "redirect_uri does not match any registered URI for this client",
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
return {};
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Build the GitHub login redirect URL, stashing OAuth params in the DB.
|
|
256
|
+
*/
|
|
257
|
+
async function buildGitHubLoginUrl(params) {
|
|
258
|
+
const pendingId = crypto.randomUUID();
|
|
259
|
+
await databases.kb.OAuthCode.put({
|
|
260
|
+
id: pendingId,
|
|
261
|
+
clientId: params.client_id,
|
|
262
|
+
userId: params.state,
|
|
263
|
+
scope: params.scope,
|
|
264
|
+
codeChallenge: params.code_challenge,
|
|
265
|
+
codeChallengeMethod: params.code_challenge_method,
|
|
266
|
+
redirectUri: params.redirect_uri,
|
|
267
|
+
type: "pending",
|
|
268
|
+
});
|
|
269
|
+
const returnPath = `/oauth/authorize?pending=${pendingId}`;
|
|
270
|
+
return `/oauth/github/login?redirect=${encodeURIComponent(returnPath)}`;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Render the login page.
|
|
274
|
+
*/
|
|
275
|
+
async function loginPage(params, errorMsg) {
|
|
276
|
+
const githubLoginUrl = await buildGitHubLoginUrl(params);
|
|
277
|
+
const clientName = (await databases.kb.OAuthClient.get(params.client_id))
|
|
278
|
+
?.clientName || params.client_id;
|
|
279
|
+
const errorHtml = errorMsg
|
|
280
|
+
? `<div class="error-msg">${escapeHtml(errorMsg)}</div>`
|
|
281
|
+
: "";
|
|
282
|
+
const html = `<!DOCTYPE html>
|
|
283
|
+
<html lang="en">
|
|
284
|
+
<head>
|
|
285
|
+
<meta charset="utf-8">
|
|
286
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
287
|
+
<title>Authorize — Harper Knowledge</title>
|
|
288
|
+
<style>
|
|
289
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
290
|
+
body {
|
|
291
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
292
|
+
background: #0f1117; color: #e1e4e8;
|
|
293
|
+
display: flex; align-items: center; justify-content: center;
|
|
294
|
+
min-height: 100vh; padding: 1rem;
|
|
295
|
+
}
|
|
296
|
+
.card {
|
|
297
|
+
background: #1c1f26; border: 1px solid #2d333b; border-radius: 12px;
|
|
298
|
+
padding: 2rem; width: 100%; max-width: 380px;
|
|
299
|
+
}
|
|
300
|
+
h1 { font-size: 1.15rem; font-weight: 600; margin-bottom: 0.25rem; }
|
|
301
|
+
.subtitle { color: #8b949e; font-size: 0.85rem; margin-bottom: 1.5rem; }
|
|
302
|
+
.client-name { color: #c9d1d9; font-weight: 500; }
|
|
303
|
+
.github-btn {
|
|
304
|
+
display: flex; align-items: center; justify-content: center; gap: 0.5rem;
|
|
305
|
+
width: 100%; padding: 0.7rem 1rem;
|
|
306
|
+
background: #238636; color: #fff; border: none; border-radius: 6px;
|
|
307
|
+
font-size: 0.95rem; font-weight: 500; cursor: pointer;
|
|
308
|
+
text-decoration: none; transition: background 0.15s;
|
|
309
|
+
}
|
|
310
|
+
.github-btn:hover { background: #2ea043; }
|
|
311
|
+
.github-btn svg { width: 20px; height: 20px; fill: currentColor; }
|
|
312
|
+
.divider {
|
|
313
|
+
display: flex; align-items: center; gap: 0.75rem;
|
|
314
|
+
margin: 1.25rem 0; color: #484f58; font-size: 0.8rem;
|
|
315
|
+
}
|
|
316
|
+
.divider::before, .divider::after {
|
|
317
|
+
content: ''; flex: 1; height: 1px; background: #2d333b;
|
|
318
|
+
}
|
|
319
|
+
.cred-toggle {
|
|
320
|
+
display: block; width: 100%; text-align: center;
|
|
321
|
+
color: #484f58; font-size: 0.8rem; background: none; border: none;
|
|
322
|
+
cursor: pointer; padding: 0.25rem; transition: color 0.15s;
|
|
323
|
+
}
|
|
324
|
+
.cred-toggle:hover { color: #8b949e; }
|
|
325
|
+
.cred-form { display: none; margin-top: 1rem; }
|
|
326
|
+
.cred-form.visible { display: block; }
|
|
327
|
+
label {
|
|
328
|
+
display: block; color: #8b949e; font-size: 0.8rem; margin-bottom: 0.25rem;
|
|
329
|
+
}
|
|
330
|
+
input[type="text"], input[type="password"] {
|
|
331
|
+
width: 100%; padding: 0.5rem 0.6rem;
|
|
332
|
+
background: #0d1117; border: 1px solid #2d333b; border-radius: 6px;
|
|
333
|
+
color: #e1e4e8; font-size: 0.9rem; margin-bottom: 0.75rem;
|
|
334
|
+
outline: none; transition: border-color 0.15s;
|
|
335
|
+
}
|
|
336
|
+
input:focus { border-color: #58a6ff; }
|
|
337
|
+
.submit-btn {
|
|
338
|
+
width: 100%; padding: 0.6rem 1rem;
|
|
339
|
+
background: #21262d; color: #c9d1d9; border: 1px solid #363b42;
|
|
340
|
+
border-radius: 6px; font-size: 0.9rem; cursor: pointer;
|
|
341
|
+
transition: background 0.15s, border-color 0.15s;
|
|
342
|
+
}
|
|
343
|
+
.submit-btn:hover { background: #30363d; border-color: #484f58; }
|
|
344
|
+
.error-msg {
|
|
345
|
+
background: #3d1214; border: 1px solid #da3633; border-radius: 6px;
|
|
346
|
+
color: #f85149; font-size: 0.85rem; padding: 0.5rem 0.75rem;
|
|
347
|
+
margin-bottom: 1rem;
|
|
348
|
+
}
|
|
349
|
+
</style>
|
|
350
|
+
</head>
|
|
351
|
+
<body>
|
|
352
|
+
<div class="card">
|
|
353
|
+
<h1>Authorize</h1>
|
|
354
|
+
<p class="subtitle"><span class="client-name">${escapeHtml(clientName)}</span> wants to access Harper Knowledge</p>
|
|
355
|
+
${errorHtml}
|
|
356
|
+
<a href="${escapeAttr(githubLoginUrl)}" class="github-btn">
|
|
357
|
+
<svg viewBox="0 0 16 16"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
|
|
358
|
+
Sign in with GitHub
|
|
359
|
+
</a>
|
|
360
|
+
<div class="divider">or</div>
|
|
361
|
+
<button type="button" class="cred-toggle" onclick="document.querySelector('.cred-form').classList.toggle('visible');this.style.display='none'">
|
|
362
|
+
Sign in with Harper credentials
|
|
363
|
+
</button>
|
|
364
|
+
<form method="POST" action="/oauth/authorize" class="cred-form${errorMsg ? " visible" : ""}">
|
|
365
|
+
<input type="hidden" name="client_id" value="${escapeAttr(params.client_id)}">
|
|
366
|
+
<input type="hidden" name="redirect_uri" value="${escapeAttr(params.redirect_uri)}">
|
|
367
|
+
<input type="hidden" name="response_type" value="${escapeAttr(params.response_type)}">
|
|
368
|
+
<input type="hidden" name="state" value="${escapeAttr(params.state)}">
|
|
369
|
+
<input type="hidden" name="code_challenge" value="${escapeAttr(params.code_challenge)}">
|
|
370
|
+
<input type="hidden" name="code_challenge_method" value="${escapeAttr(params.code_challenge_method)}">
|
|
371
|
+
<input type="hidden" name="scope" value="${escapeAttr(params.scope)}">
|
|
372
|
+
<label for="username">Username</label>
|
|
373
|
+
<input type="text" id="username" name="username" autocomplete="username" required>
|
|
374
|
+
<label for="password">Password</label>
|
|
375
|
+
<input type="password" id="password" name="password" autocomplete="current-password" required>
|
|
376
|
+
<button type="submit" class="submit-btn">Sign in</button>
|
|
377
|
+
</form>
|
|
378
|
+
</div>
|
|
379
|
+
</body>
|
|
380
|
+
</html>`;
|
|
381
|
+
return new Response(html, {
|
|
382
|
+
status: errorMsg ? 400 : 200,
|
|
383
|
+
headers: {
|
|
384
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
385
|
+
"X-Frame-Options": "DENY",
|
|
386
|
+
"X-Content-Type-Options": "nosniff",
|
|
387
|
+
"Referrer-Policy": "no-referrer",
|
|
388
|
+
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'",
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
function errorPage(message) {
|
|
393
|
+
const html = `<!DOCTYPE html>
|
|
394
|
+
<html lang="en">
|
|
395
|
+
<head>
|
|
396
|
+
<meta charset="utf-8">
|
|
397
|
+
<title>Authorization Error</title>
|
|
398
|
+
<style>
|
|
399
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
400
|
+
background: #0f1117; color: #e1e4e8;
|
|
401
|
+
display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
|
402
|
+
.error { background: #1c1f26; border: 1px solid #da3633; border-radius: 12px;
|
|
403
|
+
padding: 2rem; max-width: 420px; }
|
|
404
|
+
h1 { color: #f85149; font-size: 1.1rem; margin-bottom: 0.5rem; }
|
|
405
|
+
</style>
|
|
406
|
+
</head>
|
|
407
|
+
<body>
|
|
408
|
+
<div class="error">
|
|
409
|
+
<h1>Authorization Error</h1>
|
|
410
|
+
<p>${escapeHtml(message)}</p>
|
|
411
|
+
</div>
|
|
412
|
+
</body>
|
|
413
|
+
</html>`;
|
|
414
|
+
return new Response(html, {
|
|
415
|
+
status: 400,
|
|
416
|
+
headers: {
|
|
417
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
418
|
+
"X-Frame-Options": "DENY",
|
|
419
|
+
"X-Content-Type-Options": "nosniff",
|
|
420
|
+
"Referrer-Policy": "no-referrer",
|
|
421
|
+
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'",
|
|
422
|
+
},
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
function escapeHtml(s) {
|
|
426
|
+
return s
|
|
427
|
+
.replace(/&/g, "&")
|
|
428
|
+
.replace(/</g, "<")
|
|
429
|
+
.replace(/>/g, ">")
|
|
430
|
+
.replace(/"/g, """);
|
|
431
|
+
}
|
|
432
|
+
function escapeAttr(s) {
|
|
433
|
+
return s
|
|
434
|
+
.replace(/&/g, "&")
|
|
435
|
+
.replace(/"/g, """)
|
|
436
|
+
.replace(/</g, "<")
|
|
437
|
+
.replace(/>/g, ">");
|
|
438
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Org Membership Check
|
|
3
|
+
*
|
|
4
|
+
* Checks if a GitHub user belongs to an allowed organization.
|
|
5
|
+
* The actual GitHub OAuth login is handled by @harperfast/oauth —
|
|
6
|
+
* this module only does the org allowlist check using the session's
|
|
7
|
+
* GitHub access token.
|
|
8
|
+
*
|
|
9
|
+
* Configuration:
|
|
10
|
+
* ALLOWED_GITHUB_ORGS — Comma-separated list of allowed GitHub org logins.
|
|
11
|
+
* Empty/unset = deny all (secure by default).
|
|
12
|
+
* "*" = allow any authenticated user.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Parse the ALLOWED_GITHUB_ORGS environment variable.
|
|
16
|
+
*/
|
|
17
|
+
export declare function getAllowedOrgs(): string[];
|
|
18
|
+
/**
|
|
19
|
+
* Check if a GitHub user belongs to an allowed org.
|
|
20
|
+
*
|
|
21
|
+
* Uses the GitHub access token from the @harperfast/oauth session
|
|
22
|
+
* to query the GitHub API for the user's org memberships.
|
|
23
|
+
*
|
|
24
|
+
* - Empty/unset ALLOWED_GITHUB_ORGS → deny all
|
|
25
|
+
* - ALLOWED_GITHUB_ORGS="*" → allow any authenticated user
|
|
26
|
+
* - ALLOWED_GITHUB_ORGS="org1,org2" → allow members of listed orgs
|
|
27
|
+
*/
|
|
28
|
+
export declare function checkOrgMembership(accessToken: string): Promise<boolean>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Org Membership Check
|
|
3
|
+
*
|
|
4
|
+
* Checks if a GitHub user belongs to an allowed organization.
|
|
5
|
+
* The actual GitHub OAuth login is handled by @harperfast/oauth —
|
|
6
|
+
* this module only does the org allowlist check using the session's
|
|
7
|
+
* GitHub access token.
|
|
8
|
+
*
|
|
9
|
+
* Configuration:
|
|
10
|
+
* ALLOWED_GITHUB_ORGS — Comma-separated list of allowed GitHub org logins.
|
|
11
|
+
* Empty/unset = deny all (secure by default).
|
|
12
|
+
* "*" = allow any authenticated user.
|
|
13
|
+
*/
|
|
14
|
+
const GITHUB_API_URL = "https://api.github.com";
|
|
15
|
+
/**
|
|
16
|
+
* Parse the ALLOWED_GITHUB_ORGS environment variable.
|
|
17
|
+
*/
|
|
18
|
+
export function getAllowedOrgs() {
|
|
19
|
+
const orgsEnv = process.env.ALLOWED_GITHUB_ORGS || "";
|
|
20
|
+
return orgsEnv
|
|
21
|
+
.split(",")
|
|
22
|
+
.map((s) => s.trim())
|
|
23
|
+
.filter(Boolean);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Check if a GitHub user belongs to an allowed org.
|
|
27
|
+
*
|
|
28
|
+
* Uses the GitHub access token from the @harperfast/oauth session
|
|
29
|
+
* to query the GitHub API for the user's org memberships.
|
|
30
|
+
*
|
|
31
|
+
* - Empty/unset ALLOWED_GITHUB_ORGS → deny all
|
|
32
|
+
* - ALLOWED_GITHUB_ORGS="*" → allow any authenticated user
|
|
33
|
+
* - ALLOWED_GITHUB_ORGS="org1,org2" → allow members of listed orgs
|
|
34
|
+
*/
|
|
35
|
+
export async function checkOrgMembership(accessToken) {
|
|
36
|
+
const allowedOrgs = getAllowedOrgs();
|
|
37
|
+
if (allowedOrgs.length === 0) {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
if (allowedOrgs.includes("*")) {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
const response = await fetch(`${GITHUB_API_URL}/user/orgs`, {
|
|
45
|
+
headers: {
|
|
46
|
+
Authorization: `Bearer ${accessToken}`,
|
|
47
|
+
Accept: "application/vnd.github+json",
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
logger?.error?.("Failed to fetch GitHub user orgs");
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
const orgs = (await response.json());
|
|
55
|
+
const userOrgLogins = orgs.map((o) => o.login.toLowerCase());
|
|
56
|
+
return allowedOrgs.some((allowed) => userOrgLogins.includes(allowed.toLowerCase()));
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
logger?.error?.("GitHub org membership check failed:", error.message);
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth RSA Key Management
|
|
3
|
+
*
|
|
4
|
+
* Generates, stores, loads, and caches an RS256 key pair for signing JWT
|
|
5
|
+
* access tokens. The key is stored in the OAuthSigningKey table so all
|
|
6
|
+
* Harper worker threads share the same key.
|
|
7
|
+
*/
|
|
8
|
+
import { type JWK } from "jose";
|
|
9
|
+
/**
|
|
10
|
+
* Ensure a signing key exists. Loads from DB or generates a new one.
|
|
11
|
+
* Called once during handleApplication() on each worker thread.
|
|
12
|
+
*/
|
|
13
|
+
export declare function ensureSigningKey(): Promise<void>;
|
|
14
|
+
/**
|
|
15
|
+
* Get the private key for signing JWTs.
|
|
16
|
+
* Lazily initializes if not yet loaded.
|
|
17
|
+
*/
|
|
18
|
+
export declare function getPrivateKey(): Promise<CryptoKey>;
|
|
19
|
+
/**
|
|
20
|
+
* Get the public key JWK for token verification and JWKS endpoint.
|
|
21
|
+
* Lazily initializes if not yet loaded.
|
|
22
|
+
*/
|
|
23
|
+
export declare function getPublicKeyJwk(): Promise<JWK>;
|
|
24
|
+
/**
|
|
25
|
+
* Get the key ID for the JWT header.
|
|
26
|
+
*/
|
|
27
|
+
export declare function getKeyId(): string;
|
|
28
|
+
/**
|
|
29
|
+
* Get the JWKS response body for the /oauth/jwks endpoint.
|
|
30
|
+
*/
|
|
31
|
+
export declare function getJwks(): Promise<{
|
|
32
|
+
keys: JWK[];
|
|
33
|
+
}>;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth RSA Key Management
|
|
3
|
+
*
|
|
4
|
+
* Generates, stores, loads, and caches an RS256 key pair for signing JWT
|
|
5
|
+
* access tokens. The key is stored in the OAuthSigningKey table so all
|
|
6
|
+
* Harper worker threads share the same key.
|
|
7
|
+
*/
|
|
8
|
+
import { generateKeyPair, exportJWK, importJWK } from "jose";
|
|
9
|
+
const KEY_ID = "primary";
|
|
10
|
+
let cachedPrivateKey = null;
|
|
11
|
+
let cachedPublicKeyJwk = null;
|
|
12
|
+
/**
|
|
13
|
+
* Ensure a signing key exists. Loads from DB or generates a new one.
|
|
14
|
+
* Called once during handleApplication() on each worker thread.
|
|
15
|
+
*/
|
|
16
|
+
export async function ensureSigningKey() {
|
|
17
|
+
const existing = await databases.kb.OAuthSigningKey.get(KEY_ID);
|
|
18
|
+
if (existing && existing.publicKeyJwk && existing.privateKeyJwk) {
|
|
19
|
+
cachedPublicKeyJwk = toJwk(existing.publicKeyJwk);
|
|
20
|
+
cachedPrivateKey = (await importJWK(toJwk(existing.privateKeyJwk), "RS256"));
|
|
21
|
+
logger?.info?.("OAuth signing key loaded from database");
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
// Generate a new RSA key pair
|
|
25
|
+
const { publicKey, privateKey } = await generateKeyPair("RS256", {
|
|
26
|
+
extractable: true,
|
|
27
|
+
});
|
|
28
|
+
const pubJwk = await exportJWK(publicKey);
|
|
29
|
+
const privJwk = await exportJWK(privateKey);
|
|
30
|
+
pubJwk.kid = KEY_ID;
|
|
31
|
+
pubJwk.use = "sig";
|
|
32
|
+
pubJwk.alg = "RS256";
|
|
33
|
+
try {
|
|
34
|
+
await databases.kb.OAuthSigningKey.put({
|
|
35
|
+
id: KEY_ID,
|
|
36
|
+
publicKeyJwk: JSON.stringify(pubJwk),
|
|
37
|
+
privateKeyJwk: JSON.stringify(privJwk),
|
|
38
|
+
algorithm: "RS256",
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// Another worker may have created the key concurrently — reload
|
|
43
|
+
const reloaded = await databases.kb.OAuthSigningKey.get(KEY_ID);
|
|
44
|
+
if (reloaded && reloaded.publicKeyJwk && reloaded.privateKeyJwk) {
|
|
45
|
+
cachedPublicKeyJwk = toJwk(reloaded.publicKeyJwk);
|
|
46
|
+
cachedPrivateKey = (await importJWK(toJwk(reloaded.privateKeyJwk), "RS256"));
|
|
47
|
+
logger?.info?.("OAuth signing key loaded after concurrent creation");
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
throw new Error("Failed to create or load OAuth signing key");
|
|
51
|
+
}
|
|
52
|
+
cachedPrivateKey = privateKey;
|
|
53
|
+
cachedPublicKeyJwk = pubJwk;
|
|
54
|
+
logger?.info?.("OAuth signing key generated and stored");
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Get the private key for signing JWTs.
|
|
58
|
+
* Lazily initializes if not yet loaded.
|
|
59
|
+
*/
|
|
60
|
+
export async function getPrivateKey() {
|
|
61
|
+
if (!cachedPrivateKey) {
|
|
62
|
+
await ensureSigningKey();
|
|
63
|
+
}
|
|
64
|
+
return cachedPrivateKey;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Get the public key JWK for token verification and JWKS endpoint.
|
|
68
|
+
* Lazily initializes if not yet loaded.
|
|
69
|
+
*/
|
|
70
|
+
export async function getPublicKeyJwk() {
|
|
71
|
+
if (!cachedPublicKeyJwk) {
|
|
72
|
+
await ensureSigningKey();
|
|
73
|
+
}
|
|
74
|
+
return cachedPublicKeyJwk;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Get the key ID for the JWT header.
|
|
78
|
+
*/
|
|
79
|
+
export function getKeyId() {
|
|
80
|
+
return KEY_ID;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Get the JWKS response body for the /oauth/jwks endpoint.
|
|
84
|
+
*/
|
|
85
|
+
export async function getJwks() {
|
|
86
|
+
return { keys: [await getPublicKeyJwk()] };
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Normalize a value from Harper's table into a JWK object.
|
|
90
|
+
* Harper's `Any` column type may return the JWK as a string or wrapped object.
|
|
91
|
+
*/
|
|
92
|
+
function toJwk(value) {
|
|
93
|
+
if (typeof value === "string") {
|
|
94
|
+
return JSON.parse(value);
|
|
95
|
+
}
|
|
96
|
+
if (value && typeof value === "object") {
|
|
97
|
+
return value;
|
|
98
|
+
}
|
|
99
|
+
throw new Error(`Cannot convert ${typeof value} to JWK`);
|
|
100
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth Metadata Endpoints
|
|
3
|
+
*
|
|
4
|
+
* Serves RFC 9728 Protected Resource Metadata and RFC 8414 Authorization
|
|
5
|
+
* Server Metadata at their well-known URLs.
|
|
6
|
+
*/
|
|
7
|
+
import type { HarperRequest } from "../types.ts";
|
|
8
|
+
/**
|
|
9
|
+
* Handle GET /.well-known/oauth-protected-resource
|
|
10
|
+
*
|
|
11
|
+
* RFC 9728 — tells MCP clients where to find the authorization server
|
|
12
|
+
* and what scopes are supported.
|
|
13
|
+
*/
|
|
14
|
+
export declare function handleProtectedResourceMetadata(request: HarperRequest): Response;
|
|
15
|
+
/**
|
|
16
|
+
* Handle GET /.well-known/oauth-authorization-server
|
|
17
|
+
*
|
|
18
|
+
* RFC 8414 — describes the authorization server's capabilities,
|
|
19
|
+
* endpoints, and supported grant types.
|
|
20
|
+
*/
|
|
21
|
+
export declare function handleAuthServerMetadata(request: HarperRequest): Response;
|