@creeperhost/modlens-mcp 1.6.21 → 1.6.23
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/README.md +17 -5
- package/dist/hosted-oauth.d.ts +43 -0
- package/dist/hosted-oauth.d.ts.map +1 -0
- package/dist/hosted-oauth.js +633 -0
- package/dist/hosted-oauth.js.map +1 -0
- package/dist/hosted-policy.d.ts +7 -3
- package/dist/hosted-policy.d.ts.map +1 -1
- package/dist/hosted-policy.js +104 -7
- package/dist/hosted-policy.js.map +1 -1
- package/dist/minecraft.d.ts +2 -0
- package/dist/minecraft.d.ts.map +1 -1
- package/dist/minecraft.js +16 -0
- package/dist/minecraft.js.map +1 -1
- package/dist/repositories/mcVersion.d.ts +1 -0
- package/dist/repositories/mcVersion.d.ts.map +1 -1
- package/dist/repositories/mcVersion.js +11 -0
- package/dist/repositories/mcVersion.js.map +1 -1
- package/dist/runtime/companion.d.ts +3 -3
- package/dist/runtime/modlens-agent.jar +0 -0
- package/dist/runtime/protocol.d.ts +2 -2
- package/dist/server.js +74 -11
- package/dist/server.js.map +1 -1
- package/dist/tools/hosted-mc-index.d.ts +8 -0
- package/dist/tools/hosted-mc-index.d.ts.map +1 -0
- package/dist/tools/hosted-mc-index.js +92 -0
- package/dist/tools/hosted-mc-index.js.map +1 -0
- package/dist/tools/mc-fts.d.ts +2 -0
- package/dist/tools/mc-fts.d.ts.map +1 -1
- package/dist/tools/mc-fts.js +8 -0
- package/dist/tools/mc-fts.js.map +1 -1
- package/dist/tools/vanilla.d.ts +30 -1
- package/dist/tools/vanilla.d.ts.map +1 -1
- package/dist/tools/vanilla.js +184 -25
- package/dist/tools/vanilla.js.map +1 -1
- package/package.json +2 -1
- package/scripts/test-hosted-oauth.mjs +208 -0
- package/scripts/test-hosted-policy.mjs +67 -8
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { getDb } from "./db.js";
|
|
3
|
+
export class HostedOAuthError extends Error {
|
|
4
|
+
status;
|
|
5
|
+
code;
|
|
6
|
+
flowId;
|
|
7
|
+
constructor(message, status = 400, code = "invalid_request", flowId) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.flowId = flowId;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const sha = (value) => createHash("sha256").update(value).digest("hex");
|
|
15
|
+
const token = (prefix) => prefix + randomBytes(32).toString("base64url");
|
|
16
|
+
const now = () => Math.floor(Date.now() / 1000);
|
|
17
|
+
const json = (res, status, value, headers = {}) => {
|
|
18
|
+
res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store", ...headers });
|
|
19
|
+
res.end(JSON.stringify(value));
|
|
20
|
+
};
|
|
21
|
+
const redirect = (res, url) => {
|
|
22
|
+
res.writeHead(302, { Location: url.toString(), "Cache-Control": "no-store" });
|
|
23
|
+
res.end();
|
|
24
|
+
};
|
|
25
|
+
const htmlHeaders = {
|
|
26
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
27
|
+
"Cache-Control": "no-store",
|
|
28
|
+
"Referrer-Policy": "no-referrer",
|
|
29
|
+
"X-Content-Type-Options": "nosniff",
|
|
30
|
+
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
|
|
31
|
+
};
|
|
32
|
+
const escapeHtml = (value) => value.replaceAll("&", "&").replaceAll("<", "<")
|
|
33
|
+
.replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
34
|
+
function page(title, content, tone = "consent") {
|
|
35
|
+
const status = tone === "error" ? "CONNECTION INTERRUPTED" : "APP CONNECTION";
|
|
36
|
+
const year = new Date().getUTCFullYear();
|
|
37
|
+
return `<!doctype html>
|
|
38
|
+
<html lang="en">
|
|
39
|
+
<head>
|
|
40
|
+
<meta charset="utf-8">
|
|
41
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
42
|
+
<meta name="color-scheme" content="dark">
|
|
43
|
+
<title>${escapeHtml(title)} · ModLens</title>
|
|
44
|
+
<style>
|
|
45
|
+
:root{--page:#171c21;--panel-top:#1e2328;--panel-bottom:#171c21;--panel-deep:#13181d;--line:#2b3138;--line-dark:#13171c;--text:#f0f0f5;--muted:#8b97a5;--green:#06c200;--green-dark:#035c00;--focus:#65dcd5;--error:#ffa7cc}
|
|
46
|
+
*{box-sizing:border-box}
|
|
47
|
+
html,body{margin:0;min-width:320px;min-height:100%;background:var(--page);color:var(--text);font-family:Inter,"Segoe UI Variable","Segoe UI",sans-serif;font-size:16px;line-height:1.5}
|
|
48
|
+
body{min-height:100vh;background:linear-gradient(-45deg,transparent,#ffffff03,#ffffff0d,#ffffff1a);display:flex;flex-direction:column}
|
|
49
|
+
.page{width:100%;min-height:100vh;display:flex;flex-direction:column;justify-content:space-between}
|
|
50
|
+
main{width:100%;display:flex;flex:1;flex-direction:column;align-items:center;justify-content:center;padding:38px 17px 24px}
|
|
51
|
+
.brand{display:flex;align-items:center;justify-content:center;margin:0 auto 22px;color:white}.brand-copy{text-align:center;line-height:1}.brand-copy strong{display:block;font-size:21px;font-weight:750;letter-spacing:.13em}.brand-copy small{display:block;margin-top:7px;color:#ffffff73;font-size:10px;font-weight:650;letter-spacing:.31em}
|
|
52
|
+
.card{width:min(638px,calc(100vw - 34px));border:1px solid var(--line);outline:1px solid var(--line-dark);border-radius:10px;overflow:hidden;background:linear-gradient(180deg,var(--panel-top),var(--panel-bottom));box-shadow:0 0 25px rgba(0,0,0,.2)}
|
|
53
|
+
.content{padding:32px;text-align:left}.eyebrow{display:block;margin:0 0 7px;color:${tone === "error" ? "var(--error)" : "#72d96e"};font-size:10px;font-weight:700;letter-spacing:.17em}.content h1{margin:0;color:var(--text);font-family:"Centra No 2","Segoe UI Variable","Segoe UI",sans-serif;font-size:30px;line-height:36px;letter-spacing:-.03em;font-weight:500}.lead,.content>p{margin:9px 0 0;color:#ffffffb3;font-size:14px;line-height:1.6}
|
|
54
|
+
.app{display:flex;align-items:center;gap:12px;margin:20px 0;padding:12px;background:var(--panel-deep);border:1px solid var(--line);border-radius:6px}.app-icon{display:flex;align-items:center;justify-content:center;width:36px;height:36px;flex:0 0 auto;border-radius:50%;background:#06c20020;color:#8cdd88;font:600 12px/1 Consolas,monospace}.app-copy{min-width:0}.app-origin{display:block;overflow:hidden;text-overflow:ellipsis;color:#f0f0f5;font:600 13px/1.45 Consolas,monospace;white-space:nowrap}.app-label{display:block;color:var(--muted);font-size:11px}
|
|
55
|
+
.permissions{margin:20px 0;padding-left:22px;color:#d7dbe0;font-size:13px;line-height:1.65}.permissions li+li{margin-top:9px}.permissions li::marker{color:#48c544}
|
|
56
|
+
form{margin:0}.actions{display:grid;grid-template-columns:1fr 1.6fr;gap:12px;margin-top:24px}button{display:inline-flex;min-height:48px;align-items:center;justify-content:center;appearance:none;border:1px solid transparent;border-radius:4px;padding:10px 16px;color:white;font:500 14px/1 "Segoe UI Variable","Segoe UI",sans-serif;cursor:pointer;transition:box-shadow .18s ease,background .18s ease}button:focus-visible,a:focus-visible{outline:2px solid var(--focus);outline-offset:3px}.primary{background:linear-gradient(to left,var(--green),var(--green-dark))}.primary:hover{box-shadow:0 0 20px #42d80854}.secondary{background:#242b33;border-color:#343b44}.secondary:hover{background:#2d353e}
|
|
57
|
+
.note{margin:12px 0 0!important;color:#ffffff73!important;font-size:11px!important}.error-code{display:block;margin-top:20px;padding:12px;border:1px solid #d1005650;border-radius:4px;background:#d1005615;color:var(--error);font:12px/1.55 Consolas,monospace;overflow-wrap:anywhere}
|
|
58
|
+
footer{width:100%;margin-top:32px;padding:20px 40px;border-top:1px solid #262626;background:#0000000f;color:#ffffff73;font-size:10px}.footer-row{display:flex;align-items:center;gap:24px;max-width:1180px;margin:auto}.footer-brand{color:#fff;font-weight:750;letter-spacing:.14em}.copyright{max-width:630px;text-transform:uppercase}.footer-spacer{flex:1}.footer-links{display:flex;gap:14px}.footer-links a{color:var(--green);text-decoration:none;text-transform:uppercase}.footer-links a:hover{text-decoration:underline}
|
|
59
|
+
@media(max-width:620px){main{padding-top:28px}.content{padding:24px}.actions{grid-template-columns:1fr}.primary{grid-row:1}.secondary{grid-row:2}.app{align-items:flex-start}.footer-row{flex-direction:column;text-align:center;gap:10px}footer{padding:20px}.copyright{max-width:100%}.footer-links{flex-wrap:wrap;justify-content:center}}
|
|
60
|
+
@media(prefers-reduced-motion:reduce){button{transition:none}}
|
|
61
|
+
</style>
|
|
62
|
+
</head>
|
|
63
|
+
<body>
|
|
64
|
+
<div class="page"><main><div class="brand"><span class="brand-copy"><strong>CREEPERHOST</strong><small>MODLENS</small></span></div><section class="card"><div class="content"><span class="eyebrow">${status}</span>${content}</div></section></main>
|
|
65
|
+
<footer><div class="footer-row"><span class="footer-brand">CREEPERHOST</span><span class="copyright">© 2011 - ${year} CreeperHost® LTD. All rights reserved. Registered in England and Wales · Company #08401051 · VAT #GB 160 6059 26</span><span class="footer-spacer"></span><nav class="footer-links" aria-label="Legal"><a href="https://www.creeperhost.net/tos" target="_blank" rel="noopener">Terms</a><a href="https://www.creeperhost.net/privacy" target="_blank" rel="noopener">Privacy</a><a href="https://www.creeperhost.net/policies" target="_blank" rel="noopener">Policies</a></nav></div></footer></div>
|
|
66
|
+
</body>
|
|
67
|
+
</html>`;
|
|
68
|
+
}
|
|
69
|
+
const field = (value, path) => path.split(".").reduce((current, part) => current && typeof current === "object" && !Array.isArray(current) ? current[part] : undefined, value);
|
|
70
|
+
function secureUrl(raw, label, loopback = false) {
|
|
71
|
+
let url;
|
|
72
|
+
try {
|
|
73
|
+
url = new URL(raw);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
throw new Error(`${label} must be an absolute URL`);
|
|
77
|
+
}
|
|
78
|
+
if ((url.protocol !== "https:" && !(loopback && url.protocol === "http:"
|
|
79
|
+
&& ["127.0.0.1", "[::1]"].includes(url.hostname))) || url.username || url.password || url.hash || url.search)
|
|
80
|
+
throw new Error(`${label} must be HTTPS (or loopback HTTP), without credentials, query or fragment`);
|
|
81
|
+
return url;
|
|
82
|
+
}
|
|
83
|
+
function callbackSafe(raw) {
|
|
84
|
+
try {
|
|
85
|
+
const url = new URL(raw);
|
|
86
|
+
return raw.length <= 2048 && !url.username && !url.password && !url.hash &&
|
|
87
|
+
(url.protocol === "https:" || (url.protocol === "http:" &&
|
|
88
|
+
["127.0.0.1", "[::1]", "localhost"].includes(url.hostname))) && url.toString() === raw;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async function body(req) {
|
|
95
|
+
const chunks = [];
|
|
96
|
+
let size = 0;
|
|
97
|
+
for await (const chunk of req) {
|
|
98
|
+
size += chunk.length;
|
|
99
|
+
if (size > 32 * 1024)
|
|
100
|
+
throw new HostedOAuthError("Request too large", 413);
|
|
101
|
+
chunks.push(Buffer.from(chunk));
|
|
102
|
+
}
|
|
103
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
104
|
+
}
|
|
105
|
+
function form(raw) {
|
|
106
|
+
const params = new URLSearchParams(raw);
|
|
107
|
+
const seen = new Set();
|
|
108
|
+
for (const [key] of params) {
|
|
109
|
+
if (seen.has(key))
|
|
110
|
+
throw new HostedOAuthError("Duplicate form parameter");
|
|
111
|
+
seen.add(key);
|
|
112
|
+
}
|
|
113
|
+
return params;
|
|
114
|
+
}
|
|
115
|
+
function uniqueQuery(params) {
|
|
116
|
+
const seen = new Set();
|
|
117
|
+
for (const [key] of params) {
|
|
118
|
+
if (seen.has(key))
|
|
119
|
+
throw new HostedOAuthError("Duplicate query parameter");
|
|
120
|
+
seen.add(key);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function required(params, key) {
|
|
124
|
+
const value = params.get(key);
|
|
125
|
+
if (!value)
|
|
126
|
+
throw new HostedOAuthError(`Missing ${key}`);
|
|
127
|
+
return value;
|
|
128
|
+
}
|
|
129
|
+
function challenge(verifier) {
|
|
130
|
+
if (!/^[A-Za-z0-9._~-]{43,128}$/.test(verifier))
|
|
131
|
+
throw new HostedOAuthError("Invalid PKCE verifier", 400, "invalid_grant");
|
|
132
|
+
return createHash("sha256").update(verifier).digest("base64url");
|
|
133
|
+
}
|
|
134
|
+
export class HostedOAuth {
|
|
135
|
+
config;
|
|
136
|
+
database;
|
|
137
|
+
ready;
|
|
138
|
+
requestCounts = new Map();
|
|
139
|
+
constructor(config, database) {
|
|
140
|
+
this.config = config;
|
|
141
|
+
this.database = database;
|
|
142
|
+
}
|
|
143
|
+
audit(event, fields = {}) {
|
|
144
|
+
console.error(`[modlens] oauth ${JSON.stringify({ event, ...fields })}`);
|
|
145
|
+
}
|
|
146
|
+
clientRef(clientId) { return sha(clientId).slice(0, 12); }
|
|
147
|
+
logError(method, route, error) {
|
|
148
|
+
const incident = randomBytes(6).toString("hex");
|
|
149
|
+
this.audit("request_failed", { incident, flow: error.flowId, method: method ?? "UNKNOWN", route,
|
|
150
|
+
status: error.status, code: error.code, description: error.message });
|
|
151
|
+
return incident;
|
|
152
|
+
}
|
|
153
|
+
static async create(env = process.env, database = getDb) {
|
|
154
|
+
const resource = secureUrl(env.MODLENS_OAUTH_PUBLIC_URL ?? "", "MODLENS_OAUTH_PUBLIC_URL", true);
|
|
155
|
+
const issuer = secureUrl(env.MODLENS_OAUTH_ISSUER ?? "", "MODLENS_OAUTH_ISSUER", true).toString().replace(/\/$/, "");
|
|
156
|
+
const clientId = env.MODLENS_OAUTH_CLIENT_ID;
|
|
157
|
+
if (!clientId)
|
|
158
|
+
throw new Error("MODLENS_OAUTH_CLIENT_ID is required");
|
|
159
|
+
const scopes = env.MODLENS_OAUTH_SCOPES?.trim();
|
|
160
|
+
if (!scopes || scopes.length > 2048 || !/^[\x21-\x7e]+(?: [\x21-\x7e]+)*$/.test(scopes))
|
|
161
|
+
throw new Error("MODLENS_OAUTH_SCOPES must be a space-separated scope list");
|
|
162
|
+
const key = Buffer.from(env.MODLENS_OAUTH_STORAGE_KEY ?? "", "base64");
|
|
163
|
+
if (key.length !== 32)
|
|
164
|
+
throw new Error("MODLENS_OAUTH_STORAGE_KEY must be a base64-encoded 32-byte key");
|
|
165
|
+
const subjectField = env.MODLENS_OAUTH_SUBJECT_FIELD ?? "sub";
|
|
166
|
+
const requiredField = env.MODLENS_OAUTH_REQUIRED_FIELD;
|
|
167
|
+
if (![subjectField, requiredField].filter(Boolean).every(value => /^[A-Za-z_][A-Za-z0-9_.]*$/.test(value)))
|
|
168
|
+
throw new Error("OAuth profile field names must be dotted JSON paths");
|
|
169
|
+
if (!!requiredField !== (env.MODLENS_OAUTH_REQUIRED_VALUE !== undefined))
|
|
170
|
+
throw new Error("MODLENS_OAUTH_REQUIRED_FIELD and MODLENS_OAUTH_REQUIRED_VALUE must be configured together");
|
|
171
|
+
const path = new URL(issuer).pathname.replace(/\/$/, "");
|
|
172
|
+
const metadataUrl = new URL(`/.well-known/oauth-authorization-server${path === "/" ? "" : path}`, issuer);
|
|
173
|
+
let response = await fetch(metadataUrl, { redirect: "error", signal: AbortSignal.timeout(10000) });
|
|
174
|
+
if (response.status === 404)
|
|
175
|
+
response = await fetch(new URL(".well-known/openid-configuration", issuer + "/"), { redirect: "error", signal: AbortSignal.timeout(10000) });
|
|
176
|
+
if (!response.ok)
|
|
177
|
+
throw new Error("OAuth provider metadata unavailable");
|
|
178
|
+
const metadata = await response.json();
|
|
179
|
+
if (metadata.issuer !== issuer)
|
|
180
|
+
throw new Error("OAuth provider issuer mismatch");
|
|
181
|
+
const authorizeUrl = secureUrl(String(metadata.authorization_endpoint ?? ""), "authorization endpoint", true).toString();
|
|
182
|
+
const tokenUrl = secureUrl(String(metadata.token_endpoint ?? ""), "token endpoint", true).toString();
|
|
183
|
+
const profileUrl = secureUrl(String(env.MODLENS_OAUTH_PROFILE_URL ?? metadata.userinfo_endpoint ?? ""), "MODLENS_OAUTH_PROFILE_URL", true).toString();
|
|
184
|
+
const instance = new HostedOAuth({ resource, issuer, clientId, clientSecret: env.MODLENS_OAUTH_CLIENT_SECRET,
|
|
185
|
+
scopes, profileUrl, subjectField, requiredField, requiredValue: env.MODLENS_OAUTH_REQUIRED_VALUE,
|
|
186
|
+
key, authorizeUrl, tokenUrl, requiresIss: metadata.authorization_response_iss_parameter_supported === true }, database);
|
|
187
|
+
await instance.db();
|
|
188
|
+
return instance;
|
|
189
|
+
}
|
|
190
|
+
async db() {
|
|
191
|
+
const db = await this.database();
|
|
192
|
+
if (!this.ready)
|
|
193
|
+
this.ready = (async () => {
|
|
194
|
+
await db.$executeRawUnsafe(`CREATE TABLE IF NOT EXISTS hosted_oauth_clients (
|
|
195
|
+
id TEXT PRIMARY KEY, redirect_uris TEXT NOT NULL, created BIGINT NOT NULL)`);
|
|
196
|
+
await db.$executeRawUnsafe(`CREATE TABLE IF NOT EXISTS hosted_oauth_objects (
|
|
197
|
+
id TEXT PRIMARY KEY, kind TEXT NOT NULL, payload TEXT NOT NULL, expires BIGINT NOT NULL)`);
|
|
198
|
+
await db.$executeRawUnsafe(`CREATE TABLE IF NOT EXISTS hosted_oauth_grants (
|
|
199
|
+
id TEXT PRIMARY KEY, client_id TEXT NOT NULL, subject TEXT NOT NULL, resource TEXT NOT NULL,
|
|
200
|
+
upstream_access TEXT NOT NULL, upstream_refresh TEXT, upstream_expires BIGINT NOT NULL,
|
|
201
|
+
refresh_hash TEXT, refresh_expires BIGINT NOT NULL)`);
|
|
202
|
+
await db.$executeRawUnsafe(`CREATE TABLE IF NOT EXISTS hosted_oauth_access (
|
|
203
|
+
token_hash TEXT PRIMARY KEY, grant_id TEXT NOT NULL, expires BIGINT NOT NULL)`);
|
|
204
|
+
})().catch(error => { this.ready = undefined; throw error; });
|
|
205
|
+
await this.ready;
|
|
206
|
+
return db;
|
|
207
|
+
}
|
|
208
|
+
seal(value) {
|
|
209
|
+
const iv = randomBytes(12);
|
|
210
|
+
const cipher = createCipheriv("aes-256-gcm", this.config.key, iv);
|
|
211
|
+
const data = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
|
|
212
|
+
return Buffer.concat([iv, cipher.getAuthTag(), data]).toString("base64url");
|
|
213
|
+
}
|
|
214
|
+
open(value) {
|
|
215
|
+
const bytes = Buffer.from(value, "base64url");
|
|
216
|
+
const decipher = createDecipheriv("aes-256-gcm", this.config.key, bytes.subarray(0, 12));
|
|
217
|
+
decipher.setAuthTag(bytes.subarray(12, 28));
|
|
218
|
+
return Buffer.concat([decipher.update(bytes.subarray(28)), decipher.final()]).toString("utf8");
|
|
219
|
+
}
|
|
220
|
+
async putObject(kind, payload, ttl) {
|
|
221
|
+
const id = token("mlo_");
|
|
222
|
+
const db = await this.db();
|
|
223
|
+
await db.$executeRawUnsafe(`DELETE FROM hosted_oauth_objects WHERE expires<$1`, now());
|
|
224
|
+
await db.$executeRawUnsafe(`INSERT INTO hosted_oauth_objects (id,kind,payload,expires) VALUES ($1,$2,$3,$4)`, sha(id), kind, this.seal(JSON.stringify(payload)), now() + ttl);
|
|
225
|
+
return id;
|
|
226
|
+
}
|
|
227
|
+
async putObjectAt(id, kind, payload, ttl) {
|
|
228
|
+
const db = await this.db();
|
|
229
|
+
await db.$executeRawUnsafe(`INSERT INTO hosted_oauth_objects (id,kind,payload,expires) VALUES ($1,$2,$3,$4)
|
|
230
|
+
ON CONFLICT (id) DO UPDATE SET kind=$2,payload=$3,expires=$4`, sha(id), kind, this.seal(JSON.stringify(payload)), now() + ttl);
|
|
231
|
+
}
|
|
232
|
+
async getObject(id, kind) {
|
|
233
|
+
const db = await this.db();
|
|
234
|
+
const rows = await db.$queryRawUnsafe(`SELECT id,kind,payload,expires FROM hosted_oauth_objects
|
|
235
|
+
WHERE id=$1 AND kind=$2 AND expires>$3`, sha(id), kind, now());
|
|
236
|
+
return rows.length === 1 ? JSON.parse(this.open(rows[0].payload)) : undefined;
|
|
237
|
+
}
|
|
238
|
+
async takeObject(id, kind) {
|
|
239
|
+
const db = await this.db();
|
|
240
|
+
const rows = await db.$queryRawUnsafe(`DELETE FROM hosted_oauth_objects
|
|
241
|
+
WHERE id=$1 AND kind=$2 AND expires>$3 RETURNING id,kind,payload,expires`, sha(id), kind, now());
|
|
242
|
+
if (rows.length !== 1) {
|
|
243
|
+
const label = kind === "state" ? "provider callback" : kind === "approval" ? "consent request" : "authorization code";
|
|
244
|
+
throw new HostedOAuthError(`Expired or reused ${label}`, 400, "invalid_grant");
|
|
245
|
+
}
|
|
246
|
+
return JSON.parse(this.open(rows[0].payload));
|
|
247
|
+
}
|
|
248
|
+
async client(clientId) {
|
|
249
|
+
const db = await this.db();
|
|
250
|
+
const rows = await db.$queryRawUnsafe(`SELECT redirect_uris FROM hosted_oauth_clients WHERE id=$1`, clientId);
|
|
251
|
+
if (rows.length !== 1)
|
|
252
|
+
throw new HostedOAuthError("Unknown OAuth client", 400, "invalid_client");
|
|
253
|
+
return JSON.parse(rows[0].redirect_uris);
|
|
254
|
+
}
|
|
255
|
+
async upstreamToken(values) {
|
|
256
|
+
const headers = { "Content-Type": "application/x-www-form-urlencoded" };
|
|
257
|
+
values.set("client_id", this.config.clientId);
|
|
258
|
+
if (this.config.clientSecret)
|
|
259
|
+
headers.Authorization = `Basic ${Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString("base64")}`;
|
|
260
|
+
let response;
|
|
261
|
+
try {
|
|
262
|
+
response = await fetch(this.config.tokenUrl, { method: "POST", headers, body: values,
|
|
263
|
+
redirect: "error", signal: AbortSignal.timeout(10000) });
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
throw new HostedOAuthError("Provider token endpoint unavailable", 503, "temporarily_unavailable");
|
|
267
|
+
}
|
|
268
|
+
if (!response.ok)
|
|
269
|
+
throw new HostedOAuthError("Provider token exchange failed", response.status >= 500 ? 503 : 401, "invalid_grant");
|
|
270
|
+
let data;
|
|
271
|
+
try {
|
|
272
|
+
data = await response.json();
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
throw new HostedOAuthError("Invalid provider token response", 503, "temporarily_unavailable");
|
|
276
|
+
}
|
|
277
|
+
if (!data || typeof data.access_token !== "string" || !data.access_token ||
|
|
278
|
+
(data.token_type && data.token_type.toLowerCase() !== "bearer"))
|
|
279
|
+
throw new HostedOAuthError("Invalid provider token response", 503);
|
|
280
|
+
return data;
|
|
281
|
+
}
|
|
282
|
+
async profile(access) {
|
|
283
|
+
let response;
|
|
284
|
+
try {
|
|
285
|
+
response = await fetch(this.config.profileUrl, { headers: { Authorization: `Bearer ${access}` },
|
|
286
|
+
redirect: "error", signal: AbortSignal.timeout(10000) });
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
throw new HostedOAuthError("Provider profile unavailable", 503, "temporarily_unavailable");
|
|
290
|
+
}
|
|
291
|
+
if (!response.ok)
|
|
292
|
+
throw new HostedOAuthError("Provider profile unavailable", response.status === 401 || response.status === 403 ? 401 : 503, response.status === 401 || response.status === 403 ? "invalid_token" : "temporarily_unavailable");
|
|
293
|
+
let data;
|
|
294
|
+
try {
|
|
295
|
+
data = await response.json();
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
throw new HostedOAuthError("Invalid provider profile", 503, "temporarily_unavailable");
|
|
299
|
+
}
|
|
300
|
+
const rawSubject = field(data, this.config.subjectField);
|
|
301
|
+
const subject = typeof rawSubject === "string" ? rawSubject
|
|
302
|
+
: typeof rawSubject === "number" && Number.isSafeInteger(rawSubject) ? String(rawSubject) : "";
|
|
303
|
+
if (!subject || subject.length > 256)
|
|
304
|
+
throw new HostedOAuthError("Provider profile has no stable subject", 403, "access_denied");
|
|
305
|
+
let allowed = true;
|
|
306
|
+
if (this.config.requiredField) {
|
|
307
|
+
const actual = field(data, this.config.requiredField);
|
|
308
|
+
const configured = this.config.requiredValue;
|
|
309
|
+
let expected = configured;
|
|
310
|
+
try {
|
|
311
|
+
const parsed = JSON.parse(configured);
|
|
312
|
+
if (parsed === null || ["string", "number", "boolean"].includes(typeof parsed))
|
|
313
|
+
expected = parsed;
|
|
314
|
+
}
|
|
315
|
+
catch { /* Unquoted string values are valid configuration. */ }
|
|
316
|
+
allowed = actual === expected;
|
|
317
|
+
}
|
|
318
|
+
return { subject, allowed };
|
|
319
|
+
}
|
|
320
|
+
callbackUrl() { return new URL("/oauth/upstream/callback", this.config.resource).toString(); }
|
|
321
|
+
issuerUrl() { return this.config.resource.origin; }
|
|
322
|
+
metadataUrl() {
|
|
323
|
+
return new URL(`/.well-known/oauth-protected-resource${this.config.resource.pathname}`, this.config.resource).toString();
|
|
324
|
+
}
|
|
325
|
+
challenge(res) {
|
|
326
|
+
json(res, 401, { error: "unauthorized" }, { "WWW-Authenticate": `Bearer resource_metadata="${this.metadataUrl()}", scope="modlens"` });
|
|
327
|
+
}
|
|
328
|
+
browserError(res, error, incident) {
|
|
329
|
+
const expired = error.code === "invalid_grant" && error.message.startsWith("Expired or reused");
|
|
330
|
+
const title = expired ? "This connection request has expired" : "ModLens could not finish connecting";
|
|
331
|
+
const detail = expired
|
|
332
|
+
? "Return to Codex and start the connection again. Authorization requests are short-lived and can only be used once."
|
|
333
|
+
: "Return to Codex and try connecting again. If the problem continues, check the ModLens server logs for the matching OAuth request.";
|
|
334
|
+
const reference = incident ? ` · ref ${incident}` : "";
|
|
335
|
+
const content = `<h1>${escapeHtml(title)}</h1><p class="lead">${escapeHtml(detail)}</p>`
|
|
336
|
+
+ `<span class="error-code">${escapeHtml(error.code)} · ${escapeHtml(error.message + reference)}</span>`;
|
|
337
|
+
res.writeHead(error.status, htmlHeaders);
|
|
338
|
+
res.end(page(title, content, "error"));
|
|
339
|
+
}
|
|
340
|
+
async authenticate(req) {
|
|
341
|
+
for (const name of Object.keys(req.headers))
|
|
342
|
+
if (name.startsWith("x-modlens-"))
|
|
343
|
+
throw new HostedOAuthError("Gateway headers are not accepted in OAuth mode", 401, "invalid_token");
|
|
344
|
+
const value = req.headers.authorization;
|
|
345
|
+
if (typeof value !== "string" || !/^Bearer m[a-z]+_[A-Za-z0-9_-]{43}$/.test(value))
|
|
346
|
+
throw new HostedOAuthError("Bearer token required", 401, "invalid_token");
|
|
347
|
+
const raw = value.slice(7);
|
|
348
|
+
const db = await this.db();
|
|
349
|
+
const rows = await db.$queryRawUnsafe(`SELECT g.*, a.expires AS access_expires FROM hosted_oauth_access a
|
|
350
|
+
JOIN hosted_oauth_grants g ON g.id=a.grant_id WHERE a.token_hash=$1 AND a.expires>$2`, sha(raw), now());
|
|
351
|
+
if (rows.length !== 1 || rows[0].resource !== this.config.resource.toString())
|
|
352
|
+
throw new HostedOAuthError("Invalid access token", 401, "invalid_token");
|
|
353
|
+
const grant = rows[0];
|
|
354
|
+
if (Number(grant.upstream_expires) <= now())
|
|
355
|
+
throw new HostedOAuthError("Access token expired", 401, "invalid_token");
|
|
356
|
+
try {
|
|
357
|
+
const profile = await this.profile(this.open(grant.upstream_access));
|
|
358
|
+
if (!profile.allowed || profile.subject !== grant.subject) {
|
|
359
|
+
await this.revoke(grant.id);
|
|
360
|
+
throw new HostedOAuthError("Account access denied", 403, "access_denied");
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
catch (error) {
|
|
364
|
+
if (error instanceof HostedOAuthError && error.status === 401)
|
|
365
|
+
await this.revoke(grant.id);
|
|
366
|
+
throw error;
|
|
367
|
+
}
|
|
368
|
+
return sha(this.config.issuer + "\0" + grant.subject);
|
|
369
|
+
}
|
|
370
|
+
async revoke(id) {
|
|
371
|
+
const db = await this.db();
|
|
372
|
+
await db.$executeRawUnsafe(`DELETE FROM hosted_oauth_access WHERE grant_id=$1`, id);
|
|
373
|
+
await db.$executeRawUnsafe(`DELETE FROM hosted_oauth_grants WHERE id=$1`, id);
|
|
374
|
+
}
|
|
375
|
+
async issue(grant) {
|
|
376
|
+
const rawAccess = token("mla_");
|
|
377
|
+
const expires = Math.max(1, Math.min(300, Number(grant.upstream_expires) - now() - 5));
|
|
378
|
+
const db = await this.db();
|
|
379
|
+
await db.$executeRawUnsafe(`INSERT INTO hosted_oauth_access (token_hash,grant_id,expires) VALUES ($1,$2,$3)`, sha(rawAccess), grant.id, now() + expires);
|
|
380
|
+
return { access_token: rawAccess, token_type: "Bearer", expires_in: expires, scope: "modlens" };
|
|
381
|
+
}
|
|
382
|
+
limit(req, route, maximum) {
|
|
383
|
+
const minute = Math.floor(now() / 60);
|
|
384
|
+
const key = `${route}:${req.socket.remoteAddress ?? "unknown"}`;
|
|
385
|
+
const entry = this.requestCounts.get(key);
|
|
386
|
+
const count = entry?.minute === minute ? entry.count + 1 : 1;
|
|
387
|
+
this.requestCounts.set(key, { minute, count });
|
|
388
|
+
if (this.requestCounts.size > 10000) {
|
|
389
|
+
for (const [id, value] of this.requestCounts)
|
|
390
|
+
if (value.minute !== minute)
|
|
391
|
+
this.requestCounts.delete(id);
|
|
392
|
+
}
|
|
393
|
+
if (count > maximum)
|
|
394
|
+
throw new HostedOAuthError("Too many authorization requests", 429, "slow_down");
|
|
395
|
+
}
|
|
396
|
+
async handle(req, res, url) {
|
|
397
|
+
const route = url.pathname;
|
|
398
|
+
if (route === this.metadataUrlPath() || route === "/.well-known/oauth-protected-resource") {
|
|
399
|
+
if (req.method !== "GET")
|
|
400
|
+
throw new HostedOAuthError("Method not allowed", 405);
|
|
401
|
+
json(res, 200, { resource: this.config.resource.toString(), authorization_servers: [this.issuerUrl()],
|
|
402
|
+
scopes_supported: ["modlens"], bearer_methods_supported: ["header"] });
|
|
403
|
+
return true;
|
|
404
|
+
}
|
|
405
|
+
if (route === "/.well-known/oauth-authorization-server") {
|
|
406
|
+
if (req.method !== "GET")
|
|
407
|
+
throw new HostedOAuthError("Method not allowed", 405);
|
|
408
|
+
json(res, 200, { issuer: this.issuerUrl(), authorization_endpoint: this.issuerUrl() + "/oauth/authorize",
|
|
409
|
+
token_endpoint: this.issuerUrl() + "/oauth/token", registration_endpoint: this.issuerUrl() + "/oauth/register",
|
|
410
|
+
revocation_endpoint: this.issuerUrl() + "/oauth/revoke", response_types_supported: ["code"],
|
|
411
|
+
grant_types_supported: ["authorization_code", "refresh_token"], code_challenge_methods_supported: ["S256"],
|
|
412
|
+
token_endpoint_auth_methods_supported: ["none"], scopes_supported: ["modlens"],
|
|
413
|
+
authorization_response_iss_parameter_supported: true });
|
|
414
|
+
return true;
|
|
415
|
+
}
|
|
416
|
+
if (route === "/oauth/register" && req.method === "POST") {
|
|
417
|
+
this.limit(req, route, 120);
|
|
418
|
+
let registration;
|
|
419
|
+
try {
|
|
420
|
+
registration = JSON.parse(await body(req));
|
|
421
|
+
}
|
|
422
|
+
catch {
|
|
423
|
+
throw new HostedOAuthError("Invalid client registration");
|
|
424
|
+
}
|
|
425
|
+
const redirects = registration.redirect_uris;
|
|
426
|
+
if (!Array.isArray(redirects) || redirects.length < 1 || redirects.length > 10 ||
|
|
427
|
+
!redirects.every(value => typeof value === "string" && callbackSafe(value)) ||
|
|
428
|
+
(registration.token_endpoint_auth_method && registration.token_endpoint_auth_method !== "none"))
|
|
429
|
+
throw new HostedOAuthError("Invalid client registration");
|
|
430
|
+
const id = token("mlc_");
|
|
431
|
+
const db = await this.db();
|
|
432
|
+
await db.$executeRawUnsafe(`INSERT INTO hosted_oauth_clients (id,redirect_uris,created) VALUES ($1,$2,$3)`, id, JSON.stringify(redirects), now());
|
|
433
|
+
this.audit("client_registered", { client: this.clientRef(id), redirects: redirects.length });
|
|
434
|
+
json(res, 201, { client_id: id, redirect_uris: redirects, grant_types: ["authorization_code", "refresh_token"],
|
|
435
|
+
response_types: ["code"], token_endpoint_auth_method: "none" });
|
|
436
|
+
return true;
|
|
437
|
+
}
|
|
438
|
+
if (route === "/oauth/authorize" && req.method === "GET") {
|
|
439
|
+
this.limit(req, route, 600);
|
|
440
|
+
const params = url.searchParams;
|
|
441
|
+
uniqueQuery(params);
|
|
442
|
+
const clientId = required(params, "client_id");
|
|
443
|
+
const redirects = await this.client(clientId);
|
|
444
|
+
const callback = required(params, "redirect_uri");
|
|
445
|
+
if (!redirects.includes(callback))
|
|
446
|
+
throw new HostedOAuthError("Unregistered redirect URI");
|
|
447
|
+
if (params.get("response_type") !== "code" || params.get("code_challenge_method") !== "S256" ||
|
|
448
|
+
!/^[A-Za-z0-9_-]{43}$/.test(required(params, "code_challenge")))
|
|
449
|
+
throw new HostedOAuthError("Authorization code with S256 PKCE required");
|
|
450
|
+
const resource = params.get("resource") ?? this.config.resource.toString();
|
|
451
|
+
if (resource !== this.config.resource.toString())
|
|
452
|
+
throw new HostedOAuthError("Invalid resource");
|
|
453
|
+
const scope = params.get("scope") ?? "modlens";
|
|
454
|
+
if (scope !== "modlens")
|
|
455
|
+
throw new HostedOAuthError("Unsupported scope", 400, "invalid_scope");
|
|
456
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
457
|
+
const flowId = randomBytes(6).toString("hex");
|
|
458
|
+
const state = await this.putObject("state", { flowId, clientId, callback, resource,
|
|
459
|
+
challenge: params.get("code_challenge"), clientState: params.get("state") ?? "", verifier }, 600);
|
|
460
|
+
this.audit("authorization_started", { flow: flowId, client: this.clientRef(clientId),
|
|
461
|
+
redirectOrigin: new URL(callback).origin });
|
|
462
|
+
const upstream = new URL(this.config.authorizeUrl);
|
|
463
|
+
upstream.searchParams.set("response_type", "code");
|
|
464
|
+
upstream.searchParams.set("client_id", this.config.clientId);
|
|
465
|
+
upstream.searchParams.set("redirect_uri", this.callbackUrl());
|
|
466
|
+
upstream.searchParams.set("scope", this.config.scopes);
|
|
467
|
+
upstream.searchParams.set("state", state);
|
|
468
|
+
upstream.searchParams.set("code_challenge", challenge(verifier));
|
|
469
|
+
upstream.searchParams.set("code_challenge_method", "S256");
|
|
470
|
+
redirect(res, upstream);
|
|
471
|
+
return true;
|
|
472
|
+
}
|
|
473
|
+
if (route === "/oauth/upstream/callback" && req.method === "GET") {
|
|
474
|
+
uniqueQuery(url.searchParams);
|
|
475
|
+
const state = await this.takeObject(required(url.searchParams, "state"), "state");
|
|
476
|
+
this.audit("upstream_callback_received", { flow: state.flowId, client: this.clientRef(state.clientId),
|
|
477
|
+
providerError: url.searchParams.has("error") });
|
|
478
|
+
const callback = new URL(state.callback);
|
|
479
|
+
if (url.searchParams.has("error")) {
|
|
480
|
+
callback.searchParams.set("error", url.searchParams.get("error") ?? "access_denied");
|
|
481
|
+
}
|
|
482
|
+
else {
|
|
483
|
+
const reportedIssuer = url.searchParams.get("iss");
|
|
484
|
+
if ((this.config.requiresIss && !reportedIssuer) ||
|
|
485
|
+
(reportedIssuer && reportedIssuer !== this.config.issuer))
|
|
486
|
+
throw new HostedOAuthError("Provider issuer mismatch", 400, "invalid_grant", state.flowId);
|
|
487
|
+
const values = new URLSearchParams({ grant_type: "authorization_code", code: required(url.searchParams, "code"),
|
|
488
|
+
redirect_uri: this.callbackUrl(), code_verifier: state.verifier });
|
|
489
|
+
const upstream = await this.upstreamToken(values);
|
|
490
|
+
const profile = await this.profile(upstream.access_token);
|
|
491
|
+
if (!profile.allowed)
|
|
492
|
+
throw new HostedOAuthError("Account access denied", 403, "access_denied");
|
|
493
|
+
const approval = await this.putObject("approval", { ...state, upstream, subject: profile.subject }, 600);
|
|
494
|
+
this.audit("consent_presented", { flow: state.flowId, client: this.clientRef(state.clientId),
|
|
495
|
+
redirectOrigin: callback.origin });
|
|
496
|
+
const destination = escapeHtml(callback.origin);
|
|
497
|
+
const content = `<h1>Allow access?</h1><p>The application below wants to connect to your hosted ModLens account.</p>`
|
|
498
|
+
+ `<div class="app"><span class="app-icon" aria-hidden="true">>_</span><span class="app-copy"><strong class="app-origin">${destination}</strong><small class="app-label">Requesting application</small></span></div>`
|
|
499
|
+
+ `<ul class="permissions"><li>Run hosted ModLens tools on your behalf</li><li>Use your hosted account allowance</li></ul>`
|
|
500
|
+
+ `<form method="post" action="/oauth/approve"><input type="hidden" name="approval" value="${escapeHtml(approval)}"><div class="actions"><button class="secondary" name="decision" value="deny">Cancel</button><button class="primary" name="decision" value="allow">Allow access</button></div></form>`
|
|
501
|
+
+ `<p class="note">Only continue if you started this request in the app.</p>`;
|
|
502
|
+
res.writeHead(200, htmlHeaders);
|
|
503
|
+
res.end(page("Connect", content));
|
|
504
|
+
return true;
|
|
505
|
+
}
|
|
506
|
+
if (state.clientState)
|
|
507
|
+
callback.searchParams.set("state", state.clientState);
|
|
508
|
+
callback.searchParams.set("iss", this.issuerUrl());
|
|
509
|
+
redirect(res, callback);
|
|
510
|
+
return true;
|
|
511
|
+
}
|
|
512
|
+
if (route === "/oauth/approve" && req.method === "POST") {
|
|
513
|
+
const params = form(await body(req));
|
|
514
|
+
const approvalId = required(params, "approval");
|
|
515
|
+
const decision = params.get("decision") === "allow" ? "allow" : "deny";
|
|
516
|
+
let approval;
|
|
517
|
+
try {
|
|
518
|
+
approval = await this.takeObject(approvalId, "approval");
|
|
519
|
+
}
|
|
520
|
+
catch (error) {
|
|
521
|
+
const previous = await this.getObject(approvalId, "approval_result");
|
|
522
|
+
if (!(error instanceof HostedOAuthError) || !previous || previous.decision !== decision)
|
|
523
|
+
throw error;
|
|
524
|
+
this.audit("consent_submission_retried", { flow: previous.flowId, decision });
|
|
525
|
+
redirect(res, new URL(previous.callback));
|
|
526
|
+
return true;
|
|
527
|
+
}
|
|
528
|
+
const callback = new URL(approval.callback);
|
|
529
|
+
if (decision === "allow") {
|
|
530
|
+
const code = await this.putObject("code", approval, 60);
|
|
531
|
+
callback.searchParams.set("code", code);
|
|
532
|
+
}
|
|
533
|
+
else
|
|
534
|
+
callback.searchParams.set("error", "access_denied");
|
|
535
|
+
if (approval.clientState)
|
|
536
|
+
callback.searchParams.set("state", approval.clientState);
|
|
537
|
+
callback.searchParams.set("iss", this.issuerUrl());
|
|
538
|
+
await this.putObjectAt(approvalId, "approval_result", { flowId: approval.flowId, decision,
|
|
539
|
+
callback: callback.toString() }, 30);
|
|
540
|
+
this.audit("consent_decided", { flow: approval.flowId, client: this.clientRef(approval.clientId), decision });
|
|
541
|
+
redirect(res, callback);
|
|
542
|
+
return true;
|
|
543
|
+
}
|
|
544
|
+
if (route === "/oauth/token" && req.method === "POST") {
|
|
545
|
+
const params = form(await body(req));
|
|
546
|
+
const clientId = required(params, "client_id");
|
|
547
|
+
await this.client(clientId);
|
|
548
|
+
const grantType = required(params, "grant_type");
|
|
549
|
+
if (grantType === "authorization_code") {
|
|
550
|
+
const code = await this.takeObject(required(params, "code"), "code");
|
|
551
|
+
if (code.clientId !== clientId || code.callback !== required(params, "redirect_uri") ||
|
|
552
|
+
challenge(required(params, "code_verifier")) !== code.challenge ||
|
|
553
|
+
(params.has("resource") && params.get("resource") !== code.resource))
|
|
554
|
+
throw new HostedOAuthError("Authorization code binding failed", 400, "invalid_grant", code.flowId);
|
|
555
|
+
const grantId = token("mlg_");
|
|
556
|
+
const refresh = code.upstream.refresh_token ? token("mlr_") : undefined;
|
|
557
|
+
const grant = { id: grantId, client_id: clientId, subject: code.subject, resource: code.resource,
|
|
558
|
+
upstream_access: this.seal(code.upstream.access_token),
|
|
559
|
+
upstream_refresh: code.upstream.refresh_token ? this.seal(code.upstream.refresh_token) : null,
|
|
560
|
+
upstream_expires: now() + Math.max(1, Math.min(86400, Number(code.upstream.expires_in) || 300)),
|
|
561
|
+
refresh_hash: refresh ? sha(refresh) : null, refresh_expires: now() + 30 * 86400 };
|
|
562
|
+
const db = await this.db();
|
|
563
|
+
await db.$executeRawUnsafe(`INSERT INTO hosted_oauth_grants (id,client_id,subject,resource,upstream_access,upstream_refresh,
|
|
564
|
+
upstream_expires,refresh_hash,refresh_expires) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, grant.id, grant.client_id, grant.subject, grant.resource, grant.upstream_access, grant.upstream_refresh, grant.upstream_expires, grant.refresh_hash, grant.refresh_expires);
|
|
565
|
+
const response = { ...await this.issue(grant), ...(refresh ? { refresh_token: refresh } : {}) };
|
|
566
|
+
this.audit("token_issued", { flow: code.flowId, client: this.clientRef(clientId), refresh: !!refresh });
|
|
567
|
+
json(res, 200, response);
|
|
568
|
+
return true;
|
|
569
|
+
}
|
|
570
|
+
if (grantType === "refresh_token") {
|
|
571
|
+
if (params.has("resource") && params.get("resource") !== this.config.resource.toString())
|
|
572
|
+
throw new HostedOAuthError("Wrong resource", 400, "invalid_grant");
|
|
573
|
+
if (params.has("scope") && params.get("scope") !== "modlens")
|
|
574
|
+
throw new HostedOAuthError("Unsupported scope", 400, "invalid_scope");
|
|
575
|
+
const raw = required(params, "refresh_token");
|
|
576
|
+
const db = await this.db();
|
|
577
|
+
const rows = await db.$queryRawUnsafe(`UPDATE hosted_oauth_grants SET refresh_hash=NULL
|
|
578
|
+
WHERE refresh_hash=$1 AND client_id=$2 AND refresh_expires>$3 RETURNING *`, sha(raw), clientId, now());
|
|
579
|
+
if (rows.length !== 1 || !rows[0].upstream_refresh)
|
|
580
|
+
throw new HostedOAuthError("Invalid refresh token", 400, "invalid_grant");
|
|
581
|
+
const grant = rows[0];
|
|
582
|
+
try {
|
|
583
|
+
const oldUpstreamRefresh = grant.upstream_refresh;
|
|
584
|
+
const upstream = await this.upstreamToken(new URLSearchParams({ grant_type: "refresh_token",
|
|
585
|
+
refresh_token: this.open(oldUpstreamRefresh) }));
|
|
586
|
+
grant.upstream_access = this.seal(upstream.access_token);
|
|
587
|
+
grant.upstream_refresh = this.seal(upstream.refresh_token ?? this.open(oldUpstreamRefresh));
|
|
588
|
+
grant.upstream_expires = now() + Math.max(1, Math.min(86400, Number(upstream.expires_in) || 300));
|
|
589
|
+
await db.$executeRawUnsafe(`UPDATE hosted_oauth_grants SET upstream_access=$2,upstream_refresh=$3,
|
|
590
|
+
upstream_expires=$4 WHERE id=$1`, grant.id, grant.upstream_access, grant.upstream_refresh, grant.upstream_expires);
|
|
591
|
+
const profile = await this.profile(upstream.access_token);
|
|
592
|
+
if (profile.subject !== grant.subject || !profile.allowed)
|
|
593
|
+
throw new HostedOAuthError("Account access denied", 403, "access_denied");
|
|
594
|
+
const next = token("mlr_");
|
|
595
|
+
grant.refresh_hash = sha(next);
|
|
596
|
+
await db.$executeRawUnsafe(`UPDATE hosted_oauth_grants SET refresh_hash=$2 WHERE id=$1`, grant.id, grant.refresh_hash);
|
|
597
|
+
json(res, 200, { ...await this.issue(grant), refresh_token: next });
|
|
598
|
+
}
|
|
599
|
+
catch (error) {
|
|
600
|
+
if (error instanceof HostedOAuthError && error.status === 503) {
|
|
601
|
+
await db.$executeRawUnsafe(`UPDATE hosted_oauth_grants SET refresh_hash=$2 WHERE id=$1 AND refresh_hash IS NULL`, grant.id, sha(raw));
|
|
602
|
+
}
|
|
603
|
+
else
|
|
604
|
+
await this.revoke(grant.id);
|
|
605
|
+
throw error;
|
|
606
|
+
}
|
|
607
|
+
return true;
|
|
608
|
+
}
|
|
609
|
+
throw new HostedOAuthError("Unsupported grant type", 400, "unsupported_grant_type");
|
|
610
|
+
}
|
|
611
|
+
if (route === "/oauth/revoke" && req.method === "POST") {
|
|
612
|
+
const params = form(await body(req));
|
|
613
|
+
const clientId = required(params, "client_id");
|
|
614
|
+
await this.client(clientId);
|
|
615
|
+
const raw = required(params, "token");
|
|
616
|
+
const db = await this.db();
|
|
617
|
+
const rows = await db.$queryRawUnsafe(`SELECT id FROM hosted_oauth_grants WHERE client_id=$1 AND refresh_hash=$2`, clientId, sha(raw));
|
|
618
|
+
if (rows.length)
|
|
619
|
+
await this.revoke(rows[0].id);
|
|
620
|
+
else {
|
|
621
|
+
const access = await db.$queryRawUnsafe(`SELECT g.id FROM hosted_oauth_access a JOIN hosted_oauth_grants g ON g.id=a.grant_id
|
|
622
|
+
WHERE g.client_id=$1 AND a.token_hash=$2`, clientId, sha(raw));
|
|
623
|
+
if (access.length)
|
|
624
|
+
await this.revoke(access[0].id);
|
|
625
|
+
}
|
|
626
|
+
json(res, 200, {});
|
|
627
|
+
return true;
|
|
628
|
+
}
|
|
629
|
+
return false;
|
|
630
|
+
}
|
|
631
|
+
metadataUrlPath() { return new URL(this.metadataUrl()).pathname; }
|
|
632
|
+
}
|
|
633
|
+
//# sourceMappingURL=hosted-oauth.js.map
|