@creeperhost/modlens-mcp 1.6.21 → 1.6.22
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 +13 -5
- package/dist/hosted-oauth.d.ts +36 -0
- package/dist/hosted-oauth.d.ts.map +1 -0
- package/dist/hosted-oauth.js +522 -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 +69 -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 +186 -0
- package/scripts/test-hosted-policy.mjs +67 -8
package/README.md
CHANGED
|
@@ -704,7 +704,7 @@ All tool actions have been consolidated into **24 grouped tools** to stay within
|
|
|
704
704
|
| action | Key params | Description |
|
|
705
705
|
|--------|-----------|-------------|
|
|
706
706
|
| `search_class` | version, query | Find class by name |
|
|
707
|
-
| `get_source` | version, className, startLine, endLine, maxLines | Read decompiled source |
|
|
707
|
+
| `get_source` | version, className, startLine, endLine, maxLines | Read decompiled source locally or for enabled teams; public HTTP prepares a private class index and returns status only |
|
|
708
708
|
| `bytecode` | version, className | Raw `javap` output |
|
|
709
709
|
| `class_members` | version, className | Methods/fields with mixin target strings |
|
|
710
710
|
| `find_refs` | version, target | Classes referencing a target |
|
|
@@ -712,7 +712,9 @@ All tool actions have been consolidated into **24 grouped tools** to stay within
|
|
|
712
712
|
| `diff` | versionA, versionB | Added/removed classes between MC versions |
|
|
713
713
|
| `decompile` | version, force | Bulk decompile MC JAR (background) |
|
|
714
714
|
| `decompile_status` | version | Poll bulk decompile job |
|
|
715
|
-
| `search_code` | version, query, searchType, isRegex, limit | Regex/text search across MC source |
|
|
715
|
+
| `search_code` | version, query, searchType, isRegex, limit | Regex/text search across MC source; public HTTP returns file and line only |
|
|
716
|
+
| `source_info` | version, className | Cached class availability and total line count, without source text |
|
|
717
|
+
| `index_status` | version | Full-version source index status, without source text |
|
|
716
718
|
| `index` | version, force | Index decompiled MC into PostgreSQL FTS |
|
|
717
719
|
| `search_indexed` | version, query, limit | Fast FTS search |
|
|
718
720
|
| `search_events` | version, query?, modloader? | Find Event subclasses in decompiled source |
|
|
@@ -1065,7 +1067,9 @@ node dist/cli.js check-updates 2
|
|
|
1065
1067
|
|
|
1066
1068
|
## Hosted access limits
|
|
1067
1069
|
|
|
1068
|
-
HTTP MCP (`MCP_PORT`) enables hosted limits by default
|
|
1070
|
+
HTTP MCP (`MCP_PORT`) enables hosted limits by default; local stdio retains full access. Public HTTP returns Minecraft class locations, members, references, mixin and version analysis, and source metadata, without Minecraft source text or bytecode. `source_info` reports cached line counts; `search_code` reports matching files and lines (`0` when no exact line is available). Line numbers may differ from a client's local decompilation.
|
|
1071
|
+
|
|
1072
|
+
The first hosted Minecraft request for a version queues private full-version decompilation and indexing. Public `get_source` prioritizes that class and returns preparation status instead of source; `index_status` reports full-version progress. Bulk commands, exports, raw JAR reads, host paths, filesystem administration, and KubeJS directory access remain local-only.
|
|
1069
1073
|
|
|
1070
1074
|
| Setting | Default | Scope |
|
|
1071
1075
|
| --- | --- | --- |
|
|
@@ -1078,7 +1082,9 @@ HTTP MCP (`MCP_PORT`) enables hosted limits by default. Local stdio retains its
|
|
|
1078
1082
|
|
|
1079
1083
|
The byte allowance includes all successful tool content, including metadata, search snippets and bytecode. It measures UTF-8 JSON content before transport compression. Cached files and inbound upload bytes are excluded. Each source text field has a 32 KiB cap. Searches with `limit`/`top` are capped at 50 results; responses exceeding the byte limit require a narrower query.
|
|
1080
1084
|
|
|
1081
|
-
Use `startLine` (1-based) and `maxLines` for `mc_source get_source/bytecode`, `mod source/decompile_class`, `mod_bytecode bytecode`, and `project source/bytecode`. A range may start anywhere; the hosted cap bounds its length.
|
|
1085
|
+
Use `startLine` (1-based) and `maxLines` for team `mc_source get_source/bytecode`, `mod source/decompile_class`, `mod_bytecode bytecode`, and `project source/bytecode`. A range may start anywhere; the hosted cap bounds its length. Minecraft source indexes populate automatically after the first hosted request for a version. Ingest mods through the operator's local interface; hosted clients can upload their own Gradle environment through `project`.
|
|
1086
|
+
|
|
1087
|
+
To allow Minecraft source for specific teams, set `MODLENS_HOSTED_MC_SOURCE=1` and `MODLENS_HOSTED_MC_SOURCE_TEAMS` to comma-separated team IDs. This requires `MODLENS_HOSTED_PROXY_SECRET`; the authenticated gateway must inject `x-modlens-team-id` for verified members and remove caller-provided `x-modlens-*` headers. Other users retain metadata-only Minecraft access. Team access uses the same source and usage limits above. Local stdio is unaffected.
|
|
1082
1088
|
|
|
1083
1089
|
### Mod source access
|
|
1084
1090
|
|
|
@@ -1092,7 +1098,9 @@ npx -y @creeperhost/modlens-mcp --local-mod --request-file local-request.json
|
|
|
1092
1098
|
|
|
1093
1099
|
### Bind allowances to authenticated accounts
|
|
1094
1100
|
|
|
1095
|
-
For public
|
|
1101
|
+
For built-in OAuth sign-in, set `MODLENS_HOSTED_AUTH=oauth` and configure `MODLENS_OAUTH_PUBLIC_URL` (the public `/mcp` URL), `MODLENS_OAUTH_ISSUER`, `MODLENS_OAUTH_CLIENT_ID`, `MODLENS_OAUTH_SCOPES`, and a stable base64-encoded 32-byte `MODLENS_OAUTH_STORAGE_KEY`. Set `MODLENS_OAUTH_PROFILE_URL` unless provider discovery supplies a userinfo endpoint. The provider must support authorization code with S256 PKCE and refresh tokens for persistent sign-in. Register `<public origin>/oauth/upstream/callback` as the provider client's redirect URI. `MODLENS_OAUTH_CLIENT_SECRET` is optional. `MODLENS_OAUTH_SUBJECT_FIELD` defaults to `sub`; optional `MODLENS_OAUTH_REQUIRED_FIELD` and `MODLENS_OAUTH_REQUIRED_VALUE` restrict access using a profile field. The OAuth routes and well-known metadata must be reachable through the public HTTPS origin. OAuth mode does not enable hosted Minecraft source.
|
|
1102
|
+
|
|
1103
|
+
For gateway authentication (the default HTTP mode), put an authenticated HTTPS gateway in front of the server. Set `MODLENS_HOSTED_PROXY_SECRET` to a random secret of at least 32 characters. The gateway must remove caller-provided `x-modlens-*` headers and inject:
|
|
1096
1104
|
|
|
1097
1105
|
- `x-modlens-proxy-secret`: the server's secret, never sent to clients.
|
|
1098
1106
|
- `x-modlens-user-id`: a stable, verified account identifier selected by the gateway. Reconnecting, rotating tokens, or using another client must retain this identifier.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { getDb } from "./db.js";
|
|
3
|
+
type Database = Awaited<ReturnType<typeof getDb>>;
|
|
4
|
+
export declare class HostedOAuthError extends Error {
|
|
5
|
+
status: number;
|
|
6
|
+
code: string;
|
|
7
|
+
constructor(message: string, status?: number, code?: string);
|
|
8
|
+
}
|
|
9
|
+
export declare class HostedOAuth {
|
|
10
|
+
private config;
|
|
11
|
+
private database;
|
|
12
|
+
private ready?;
|
|
13
|
+
private requestCounts;
|
|
14
|
+
private constructor();
|
|
15
|
+
static create(env?: NodeJS.ProcessEnv, database?: () => Promise<Database>): Promise<HostedOAuth>;
|
|
16
|
+
private db;
|
|
17
|
+
private seal;
|
|
18
|
+
private open;
|
|
19
|
+
private putObject;
|
|
20
|
+
private takeObject;
|
|
21
|
+
private client;
|
|
22
|
+
private upstreamToken;
|
|
23
|
+
private profile;
|
|
24
|
+
private callbackUrl;
|
|
25
|
+
private issuerUrl;
|
|
26
|
+
private metadataUrl;
|
|
27
|
+
challenge(res: ServerResponse): void;
|
|
28
|
+
authenticate(req: IncomingMessage): Promise<string>;
|
|
29
|
+
private revoke;
|
|
30
|
+
private issue;
|
|
31
|
+
private limit;
|
|
32
|
+
handle(req: IncomingMessage, res: ServerResponse, url: URL): Promise<boolean>;
|
|
33
|
+
private metadataUrlPath;
|
|
34
|
+
}
|
|
35
|
+
export {};
|
|
36
|
+
//# sourceMappingURL=hosted-oauth.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hosted-oauth.d.ts","sourceRoot":"","sources":["../src/hosted-oauth.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AACjE,OAAO,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAEhC,KAAK,QAAQ,GAAG,OAAO,CAAC,UAAU,CAAC,OAAO,KAAK,CAAC,CAAC,CAAC;AAclD,qBAAa,gBAAiB,SAAQ,KAAK;IACH,MAAM;IAAe,IAAI;gBAAjD,OAAO,EAAE,MAAM,EAAS,MAAM,SAAM,EAAS,IAAI,SAAoB;CACpF;AA0ED,qBAAa,WAAW;IAGA,OAAO,CAAC,MAAM;IAAc,OAAO,CAAC,QAAQ;IAFhE,OAAO,CAAC,KAAK,CAAC,CAAgB;IAC9B,OAAO,CAAC,aAAa,CAAwD;IAC7E,OAAO;WAEM,MAAM,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,EAAE,QAAQ,GAAE,MAAM,OAAO,CAAC,QAAQ,CAAS,GAAG,OAAO,CAAC,WAAW,CAAC;YAmC5G,EAAE;IAkBhB,OAAO,CAAC,IAAI;IAOZ,OAAO,CAAC,IAAI;YAOE,SAAS;YAST,UAAU;YAQV,MAAM;YAQN,aAAa;YAiBb,OAAO;IA6BrB,OAAO,CAAC,WAAW;IACnB,OAAO,CAAC,SAAS;IACjB,OAAO,CAAC,WAAW;IAInB,SAAS,CAAC,GAAG,EAAE,cAAc,GAAG,IAAI;IAI9B,YAAY,CAAC,GAAG,EAAE,eAAe,GAAG,OAAO,CAAC,MAAM,CAAC;YA4B3C,MAAM;YAMN,KAAK;IASnB,OAAO,CAAC,KAAK;IAYP,MAAM,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,EAAE,GAAG,EAAE,GAAG,GAAG,OAAO,CAAC,OAAO,CAAC;IAoMnF,OAAO,CAAC,eAAe;CAC1B"}
|
|
@@ -0,0 +1,522 @@
|
|
|
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
|
+
constructor(message, status = 400, code = "invalid_request") {
|
|
7
|
+
super(message);
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.code = code;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const sha = (value) => createHash("sha256").update(value).digest("hex");
|
|
13
|
+
const token = (prefix) => prefix + randomBytes(32).toString("base64url");
|
|
14
|
+
const now = () => Math.floor(Date.now() / 1000);
|
|
15
|
+
const json = (res, status, value, headers = {}) => {
|
|
16
|
+
res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store", ...headers });
|
|
17
|
+
res.end(JSON.stringify(value));
|
|
18
|
+
};
|
|
19
|
+
const redirect = (res, url) => {
|
|
20
|
+
res.writeHead(302, { Location: url.toString(), "Cache-Control": "no-store" });
|
|
21
|
+
res.end();
|
|
22
|
+
};
|
|
23
|
+
const field = (value, path) => path.split(".").reduce((current, part) => current && typeof current === "object" && !Array.isArray(current) ? current[part] : undefined, value);
|
|
24
|
+
function secureUrl(raw, label, loopback = false) {
|
|
25
|
+
let url;
|
|
26
|
+
try {
|
|
27
|
+
url = new URL(raw);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
throw new Error(`${label} must be an absolute URL`);
|
|
31
|
+
}
|
|
32
|
+
if ((url.protocol !== "https:" && !(loopback && url.protocol === "http:"
|
|
33
|
+
&& ["127.0.0.1", "[::1]"].includes(url.hostname))) || url.username || url.password || url.hash || url.search)
|
|
34
|
+
throw new Error(`${label} must be HTTPS (or loopback HTTP), without credentials, query or fragment`);
|
|
35
|
+
return url;
|
|
36
|
+
}
|
|
37
|
+
function callbackSafe(raw) {
|
|
38
|
+
try {
|
|
39
|
+
const url = new URL(raw);
|
|
40
|
+
return raw.length <= 2048 && !url.username && !url.password && !url.hash &&
|
|
41
|
+
(url.protocol === "https:" || (url.protocol === "http:" &&
|
|
42
|
+
["127.0.0.1", "[::1]", "localhost"].includes(url.hostname))) && url.toString() === raw;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async function body(req) {
|
|
49
|
+
const chunks = [];
|
|
50
|
+
let size = 0;
|
|
51
|
+
for await (const chunk of req) {
|
|
52
|
+
size += chunk.length;
|
|
53
|
+
if (size > 32 * 1024)
|
|
54
|
+
throw new HostedOAuthError("Request too large", 413);
|
|
55
|
+
chunks.push(Buffer.from(chunk));
|
|
56
|
+
}
|
|
57
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
58
|
+
}
|
|
59
|
+
function form(raw) {
|
|
60
|
+
const params = new URLSearchParams(raw);
|
|
61
|
+
const seen = new Set();
|
|
62
|
+
for (const [key] of params) {
|
|
63
|
+
if (seen.has(key))
|
|
64
|
+
throw new HostedOAuthError("Duplicate form parameter");
|
|
65
|
+
seen.add(key);
|
|
66
|
+
}
|
|
67
|
+
return params;
|
|
68
|
+
}
|
|
69
|
+
function uniqueQuery(params) {
|
|
70
|
+
const seen = new Set();
|
|
71
|
+
for (const [key] of params) {
|
|
72
|
+
if (seen.has(key))
|
|
73
|
+
throw new HostedOAuthError("Duplicate query parameter");
|
|
74
|
+
seen.add(key);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function required(params, key) {
|
|
78
|
+
const value = params.get(key);
|
|
79
|
+
if (!value)
|
|
80
|
+
throw new HostedOAuthError(`Missing ${key}`);
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
function challenge(verifier) {
|
|
84
|
+
if (!/^[A-Za-z0-9._~-]{43,128}$/.test(verifier))
|
|
85
|
+
throw new HostedOAuthError("Invalid PKCE verifier", 400, "invalid_grant");
|
|
86
|
+
return createHash("sha256").update(verifier).digest("base64url");
|
|
87
|
+
}
|
|
88
|
+
export class HostedOAuth {
|
|
89
|
+
config;
|
|
90
|
+
database;
|
|
91
|
+
ready;
|
|
92
|
+
requestCounts = new Map();
|
|
93
|
+
constructor(config, database) {
|
|
94
|
+
this.config = config;
|
|
95
|
+
this.database = database;
|
|
96
|
+
}
|
|
97
|
+
static async create(env = process.env, database = getDb) {
|
|
98
|
+
const resource = secureUrl(env.MODLENS_OAUTH_PUBLIC_URL ?? "", "MODLENS_OAUTH_PUBLIC_URL", true);
|
|
99
|
+
const issuer = secureUrl(env.MODLENS_OAUTH_ISSUER ?? "", "MODLENS_OAUTH_ISSUER", true).toString().replace(/\/$/, "");
|
|
100
|
+
const clientId = env.MODLENS_OAUTH_CLIENT_ID;
|
|
101
|
+
if (!clientId)
|
|
102
|
+
throw new Error("MODLENS_OAUTH_CLIENT_ID is required");
|
|
103
|
+
const scopes = env.MODLENS_OAUTH_SCOPES?.trim();
|
|
104
|
+
if (!scopes || scopes.length > 2048 || !/^[\x21-\x7e]+(?: [\x21-\x7e]+)*$/.test(scopes))
|
|
105
|
+
throw new Error("MODLENS_OAUTH_SCOPES must be a space-separated scope list");
|
|
106
|
+
const key = Buffer.from(env.MODLENS_OAUTH_STORAGE_KEY ?? "", "base64");
|
|
107
|
+
if (key.length !== 32)
|
|
108
|
+
throw new Error("MODLENS_OAUTH_STORAGE_KEY must be a base64-encoded 32-byte key");
|
|
109
|
+
const subjectField = env.MODLENS_OAUTH_SUBJECT_FIELD ?? "sub";
|
|
110
|
+
const requiredField = env.MODLENS_OAUTH_REQUIRED_FIELD;
|
|
111
|
+
if (![subjectField, requiredField].filter(Boolean).every(value => /^[A-Za-z_][A-Za-z0-9_.]*$/.test(value)))
|
|
112
|
+
throw new Error("OAuth profile field names must be dotted JSON paths");
|
|
113
|
+
if (!!requiredField !== (env.MODLENS_OAUTH_REQUIRED_VALUE !== undefined))
|
|
114
|
+
throw new Error("MODLENS_OAUTH_REQUIRED_FIELD and MODLENS_OAUTH_REQUIRED_VALUE must be configured together");
|
|
115
|
+
const path = new URL(issuer).pathname.replace(/\/$/, "");
|
|
116
|
+
const metadataUrl = new URL(`/.well-known/oauth-authorization-server${path === "/" ? "" : path}`, issuer);
|
|
117
|
+
let response = await fetch(metadataUrl, { redirect: "error", signal: AbortSignal.timeout(10000) });
|
|
118
|
+
if (response.status === 404)
|
|
119
|
+
response = await fetch(new URL(".well-known/openid-configuration", issuer + "/"), { redirect: "error", signal: AbortSignal.timeout(10000) });
|
|
120
|
+
if (!response.ok)
|
|
121
|
+
throw new Error("OAuth provider metadata unavailable");
|
|
122
|
+
const metadata = await response.json();
|
|
123
|
+
if (metadata.issuer !== issuer)
|
|
124
|
+
throw new Error("OAuth provider issuer mismatch");
|
|
125
|
+
const authorizeUrl = secureUrl(String(metadata.authorization_endpoint ?? ""), "authorization endpoint", true).toString();
|
|
126
|
+
const tokenUrl = secureUrl(String(metadata.token_endpoint ?? ""), "token endpoint", true).toString();
|
|
127
|
+
const profileUrl = secureUrl(String(env.MODLENS_OAUTH_PROFILE_URL ?? metadata.userinfo_endpoint ?? ""), "MODLENS_OAUTH_PROFILE_URL", true).toString();
|
|
128
|
+
const instance = new HostedOAuth({ resource, issuer, clientId, clientSecret: env.MODLENS_OAUTH_CLIENT_SECRET,
|
|
129
|
+
scopes, profileUrl, subjectField, requiredField, requiredValue: env.MODLENS_OAUTH_REQUIRED_VALUE,
|
|
130
|
+
key, authorizeUrl, tokenUrl, requiresIss: metadata.authorization_response_iss_parameter_supported === true }, database);
|
|
131
|
+
await instance.db();
|
|
132
|
+
return instance;
|
|
133
|
+
}
|
|
134
|
+
async db() {
|
|
135
|
+
const db = await this.database();
|
|
136
|
+
if (!this.ready)
|
|
137
|
+
this.ready = (async () => {
|
|
138
|
+
await db.$executeRawUnsafe(`CREATE TABLE IF NOT EXISTS hosted_oauth_clients (
|
|
139
|
+
id TEXT PRIMARY KEY, redirect_uris TEXT NOT NULL, created BIGINT NOT NULL)`);
|
|
140
|
+
await db.$executeRawUnsafe(`CREATE TABLE IF NOT EXISTS hosted_oauth_objects (
|
|
141
|
+
id TEXT PRIMARY KEY, kind TEXT NOT NULL, payload TEXT NOT NULL, expires BIGINT NOT NULL)`);
|
|
142
|
+
await db.$executeRawUnsafe(`CREATE TABLE IF NOT EXISTS hosted_oauth_grants (
|
|
143
|
+
id TEXT PRIMARY KEY, client_id TEXT NOT NULL, subject TEXT NOT NULL, resource TEXT NOT NULL,
|
|
144
|
+
upstream_access TEXT NOT NULL, upstream_refresh TEXT, upstream_expires BIGINT NOT NULL,
|
|
145
|
+
refresh_hash TEXT, refresh_expires BIGINT NOT NULL)`);
|
|
146
|
+
await db.$executeRawUnsafe(`CREATE TABLE IF NOT EXISTS hosted_oauth_access (
|
|
147
|
+
token_hash TEXT PRIMARY KEY, grant_id TEXT NOT NULL, expires BIGINT NOT NULL)`);
|
|
148
|
+
})().catch(error => { this.ready = undefined; throw error; });
|
|
149
|
+
await this.ready;
|
|
150
|
+
return db;
|
|
151
|
+
}
|
|
152
|
+
seal(value) {
|
|
153
|
+
const iv = randomBytes(12);
|
|
154
|
+
const cipher = createCipheriv("aes-256-gcm", this.config.key, iv);
|
|
155
|
+
const data = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
|
|
156
|
+
return Buffer.concat([iv, cipher.getAuthTag(), data]).toString("base64url");
|
|
157
|
+
}
|
|
158
|
+
open(value) {
|
|
159
|
+
const bytes = Buffer.from(value, "base64url");
|
|
160
|
+
const decipher = createDecipheriv("aes-256-gcm", this.config.key, bytes.subarray(0, 12));
|
|
161
|
+
decipher.setAuthTag(bytes.subarray(12, 28));
|
|
162
|
+
return Buffer.concat([decipher.update(bytes.subarray(28)), decipher.final()]).toString("utf8");
|
|
163
|
+
}
|
|
164
|
+
async putObject(kind, payload, ttl) {
|
|
165
|
+
const id = token("mlo_");
|
|
166
|
+
const db = await this.db();
|
|
167
|
+
await db.$executeRawUnsafe(`DELETE FROM hosted_oauth_objects WHERE expires<$1`, now());
|
|
168
|
+
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);
|
|
169
|
+
return id;
|
|
170
|
+
}
|
|
171
|
+
async takeObject(id, kind) {
|
|
172
|
+
const db = await this.db();
|
|
173
|
+
const rows = await db.$queryRawUnsafe(`DELETE FROM hosted_oauth_objects
|
|
174
|
+
WHERE id=$1 AND kind=$2 AND expires>$3 RETURNING id,kind,payload,expires`, sha(id), kind, now());
|
|
175
|
+
if (rows.length !== 1)
|
|
176
|
+
throw new HostedOAuthError("Expired or reused authorization", 400, "invalid_grant");
|
|
177
|
+
return JSON.parse(this.open(rows[0].payload));
|
|
178
|
+
}
|
|
179
|
+
async client(clientId) {
|
|
180
|
+
const db = await this.db();
|
|
181
|
+
const rows = await db.$queryRawUnsafe(`SELECT redirect_uris FROM hosted_oauth_clients WHERE id=$1`, clientId);
|
|
182
|
+
if (rows.length !== 1)
|
|
183
|
+
throw new HostedOAuthError("Unknown OAuth client", 400, "invalid_client");
|
|
184
|
+
return JSON.parse(rows[0].redirect_uris);
|
|
185
|
+
}
|
|
186
|
+
async upstreamToken(values) {
|
|
187
|
+
const headers = { "Content-Type": "application/x-www-form-urlencoded" };
|
|
188
|
+
values.set("client_id", this.config.clientId);
|
|
189
|
+
if (this.config.clientSecret)
|
|
190
|
+
headers.Authorization = `Basic ${Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString("base64")}`;
|
|
191
|
+
let response;
|
|
192
|
+
try {
|
|
193
|
+
response = await fetch(this.config.tokenUrl, { method: "POST", headers, body: values,
|
|
194
|
+
redirect: "error", signal: AbortSignal.timeout(10000) });
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
throw new HostedOAuthError("Provider token endpoint unavailable", 503, "temporarily_unavailable");
|
|
198
|
+
}
|
|
199
|
+
if (!response.ok)
|
|
200
|
+
throw new HostedOAuthError("Provider token exchange failed", response.status >= 500 ? 503 : 401, "invalid_grant");
|
|
201
|
+
let data;
|
|
202
|
+
try {
|
|
203
|
+
data = await response.json();
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
throw new HostedOAuthError("Invalid provider token response", 503, "temporarily_unavailable");
|
|
207
|
+
}
|
|
208
|
+
if (!data || typeof data.access_token !== "string" || !data.access_token ||
|
|
209
|
+
(data.token_type && data.token_type.toLowerCase() !== "bearer"))
|
|
210
|
+
throw new HostedOAuthError("Invalid provider token response", 503);
|
|
211
|
+
return data;
|
|
212
|
+
}
|
|
213
|
+
async profile(access) {
|
|
214
|
+
let response;
|
|
215
|
+
try {
|
|
216
|
+
response = await fetch(this.config.profileUrl, { headers: { Authorization: `Bearer ${access}` },
|
|
217
|
+
redirect: "error", signal: AbortSignal.timeout(10000) });
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
throw new HostedOAuthError("Provider profile unavailable", 503, "temporarily_unavailable");
|
|
221
|
+
}
|
|
222
|
+
if (!response.ok)
|
|
223
|
+
throw new HostedOAuthError("Provider profile unavailable", response.status === 401 || response.status === 403 ? 401 : 503, response.status === 401 || response.status === 403 ? "invalid_token" : "temporarily_unavailable");
|
|
224
|
+
let data;
|
|
225
|
+
try {
|
|
226
|
+
data = await response.json();
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
throw new HostedOAuthError("Invalid provider profile", 503, "temporarily_unavailable");
|
|
230
|
+
}
|
|
231
|
+
const rawSubject = field(data, this.config.subjectField);
|
|
232
|
+
const subject = typeof rawSubject === "string" ? rawSubject
|
|
233
|
+
: typeof rawSubject === "number" && Number.isSafeInteger(rawSubject) ? String(rawSubject) : "";
|
|
234
|
+
if (!subject || subject.length > 256)
|
|
235
|
+
throw new HostedOAuthError("Provider profile has no stable subject", 403, "access_denied");
|
|
236
|
+
let allowed = true;
|
|
237
|
+
if (this.config.requiredField) {
|
|
238
|
+
const actual = field(data, this.config.requiredField);
|
|
239
|
+
const configured = this.config.requiredValue;
|
|
240
|
+
let expected = configured;
|
|
241
|
+
try {
|
|
242
|
+
const parsed = JSON.parse(configured);
|
|
243
|
+
if (parsed === null || ["string", "number", "boolean"].includes(typeof parsed))
|
|
244
|
+
expected = parsed;
|
|
245
|
+
}
|
|
246
|
+
catch { /* Unquoted string values are valid configuration. */ }
|
|
247
|
+
allowed = actual === expected;
|
|
248
|
+
}
|
|
249
|
+
return { subject, allowed };
|
|
250
|
+
}
|
|
251
|
+
callbackUrl() { return new URL("/oauth/upstream/callback", this.config.resource).toString(); }
|
|
252
|
+
issuerUrl() { return this.config.resource.origin; }
|
|
253
|
+
metadataUrl() {
|
|
254
|
+
return new URL(`/.well-known/oauth-protected-resource${this.config.resource.pathname}`, this.config.resource).toString();
|
|
255
|
+
}
|
|
256
|
+
challenge(res) {
|
|
257
|
+
json(res, 401, { error: "unauthorized" }, { "WWW-Authenticate": `Bearer resource_metadata="${this.metadataUrl()}", scope="modlens"` });
|
|
258
|
+
}
|
|
259
|
+
async authenticate(req) {
|
|
260
|
+
for (const name of Object.keys(req.headers))
|
|
261
|
+
if (name.startsWith("x-modlens-"))
|
|
262
|
+
throw new HostedOAuthError("Gateway headers are not accepted in OAuth mode", 401, "invalid_token");
|
|
263
|
+
const value = req.headers.authorization;
|
|
264
|
+
if (typeof value !== "string" || !/^Bearer m[a-z]+_[A-Za-z0-9_-]{43}$/.test(value))
|
|
265
|
+
throw new HostedOAuthError("Bearer token required", 401, "invalid_token");
|
|
266
|
+
const raw = value.slice(7);
|
|
267
|
+
const db = await this.db();
|
|
268
|
+
const rows = await db.$queryRawUnsafe(`SELECT g.*, a.expires AS access_expires FROM hosted_oauth_access a
|
|
269
|
+
JOIN hosted_oauth_grants g ON g.id=a.grant_id WHERE a.token_hash=$1 AND a.expires>$2`, sha(raw), now());
|
|
270
|
+
if (rows.length !== 1 || rows[0].resource !== this.config.resource.toString())
|
|
271
|
+
throw new HostedOAuthError("Invalid access token", 401, "invalid_token");
|
|
272
|
+
const grant = rows[0];
|
|
273
|
+
if (Number(grant.upstream_expires) <= now())
|
|
274
|
+
throw new HostedOAuthError("Access token expired", 401, "invalid_token");
|
|
275
|
+
try {
|
|
276
|
+
const profile = await this.profile(this.open(grant.upstream_access));
|
|
277
|
+
if (!profile.allowed || profile.subject !== grant.subject) {
|
|
278
|
+
await this.revoke(grant.id);
|
|
279
|
+
throw new HostedOAuthError("Account access denied", 403, "access_denied");
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
if (error instanceof HostedOAuthError && error.status === 401)
|
|
284
|
+
await this.revoke(grant.id);
|
|
285
|
+
throw error;
|
|
286
|
+
}
|
|
287
|
+
return sha(this.config.issuer + "\0" + grant.subject);
|
|
288
|
+
}
|
|
289
|
+
async revoke(id) {
|
|
290
|
+
const db = await this.db();
|
|
291
|
+
await db.$executeRawUnsafe(`DELETE FROM hosted_oauth_access WHERE grant_id=$1`, id);
|
|
292
|
+
await db.$executeRawUnsafe(`DELETE FROM hosted_oauth_grants WHERE id=$1`, id);
|
|
293
|
+
}
|
|
294
|
+
async issue(grant) {
|
|
295
|
+
const rawAccess = token("mla_");
|
|
296
|
+
const expires = Math.max(1, Math.min(300, Number(grant.upstream_expires) - now() - 5));
|
|
297
|
+
const db = await this.db();
|
|
298
|
+
await db.$executeRawUnsafe(`INSERT INTO hosted_oauth_access (token_hash,grant_id,expires) VALUES ($1,$2,$3)`, sha(rawAccess), grant.id, now() + expires);
|
|
299
|
+
return { access_token: rawAccess, token_type: "Bearer", expires_in: expires, scope: "modlens" };
|
|
300
|
+
}
|
|
301
|
+
limit(req, route, maximum) {
|
|
302
|
+
const minute = Math.floor(now() / 60);
|
|
303
|
+
const key = `${route}:${req.socket.remoteAddress ?? "unknown"}`;
|
|
304
|
+
const entry = this.requestCounts.get(key);
|
|
305
|
+
const count = entry?.minute === minute ? entry.count + 1 : 1;
|
|
306
|
+
this.requestCounts.set(key, { minute, count });
|
|
307
|
+
if (this.requestCounts.size > 10000) {
|
|
308
|
+
for (const [id, value] of this.requestCounts)
|
|
309
|
+
if (value.minute !== minute)
|
|
310
|
+
this.requestCounts.delete(id);
|
|
311
|
+
}
|
|
312
|
+
if (count > maximum)
|
|
313
|
+
throw new HostedOAuthError("Too many authorization requests", 429, "slow_down");
|
|
314
|
+
}
|
|
315
|
+
async handle(req, res, url) {
|
|
316
|
+
const route = url.pathname;
|
|
317
|
+
if (route === this.metadataUrlPath() || route === "/.well-known/oauth-protected-resource") {
|
|
318
|
+
if (req.method !== "GET")
|
|
319
|
+
throw new HostedOAuthError("Method not allowed", 405);
|
|
320
|
+
json(res, 200, { resource: this.config.resource.toString(), authorization_servers: [this.issuerUrl()],
|
|
321
|
+
scopes_supported: ["modlens"], bearer_methods_supported: ["header"] });
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
if (route === "/.well-known/oauth-authorization-server") {
|
|
325
|
+
if (req.method !== "GET")
|
|
326
|
+
throw new HostedOAuthError("Method not allowed", 405);
|
|
327
|
+
json(res, 200, { issuer: this.issuerUrl(), authorization_endpoint: this.issuerUrl() + "/oauth/authorize",
|
|
328
|
+
token_endpoint: this.issuerUrl() + "/oauth/token", registration_endpoint: this.issuerUrl() + "/oauth/register",
|
|
329
|
+
revocation_endpoint: this.issuerUrl() + "/oauth/revoke", response_types_supported: ["code"],
|
|
330
|
+
grant_types_supported: ["authorization_code", "refresh_token"], code_challenge_methods_supported: ["S256"],
|
|
331
|
+
token_endpoint_auth_methods_supported: ["none"], scopes_supported: ["modlens"],
|
|
332
|
+
authorization_response_iss_parameter_supported: true });
|
|
333
|
+
return true;
|
|
334
|
+
}
|
|
335
|
+
if (route === "/oauth/register" && req.method === "POST") {
|
|
336
|
+
this.limit(req, route, 120);
|
|
337
|
+
let registration;
|
|
338
|
+
try {
|
|
339
|
+
registration = JSON.parse(await body(req));
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
throw new HostedOAuthError("Invalid client registration");
|
|
343
|
+
}
|
|
344
|
+
const redirects = registration.redirect_uris;
|
|
345
|
+
if (!Array.isArray(redirects) || redirects.length < 1 || redirects.length > 10 ||
|
|
346
|
+
!redirects.every(value => typeof value === "string" && callbackSafe(value)) ||
|
|
347
|
+
(registration.token_endpoint_auth_method && registration.token_endpoint_auth_method !== "none"))
|
|
348
|
+
throw new HostedOAuthError("Invalid client registration");
|
|
349
|
+
const id = token("mlc_");
|
|
350
|
+
const db = await this.db();
|
|
351
|
+
await db.$executeRawUnsafe(`INSERT INTO hosted_oauth_clients (id,redirect_uris,created) VALUES ($1,$2,$3)`, id, JSON.stringify(redirects), now());
|
|
352
|
+
json(res, 201, { client_id: id, redirect_uris: redirects, grant_types: ["authorization_code", "refresh_token"],
|
|
353
|
+
response_types: ["code"], token_endpoint_auth_method: "none" });
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
if (route === "/oauth/authorize" && req.method === "GET") {
|
|
357
|
+
this.limit(req, route, 600);
|
|
358
|
+
const params = url.searchParams;
|
|
359
|
+
uniqueQuery(params);
|
|
360
|
+
const clientId = required(params, "client_id");
|
|
361
|
+
const redirects = await this.client(clientId);
|
|
362
|
+
const callback = required(params, "redirect_uri");
|
|
363
|
+
if (!redirects.includes(callback))
|
|
364
|
+
throw new HostedOAuthError("Unregistered redirect URI");
|
|
365
|
+
if (params.get("response_type") !== "code" || params.get("code_challenge_method") !== "S256" ||
|
|
366
|
+
!/^[A-Za-z0-9_-]{43}$/.test(required(params, "code_challenge")))
|
|
367
|
+
throw new HostedOAuthError("Authorization code with S256 PKCE required");
|
|
368
|
+
const resource = params.get("resource") ?? this.config.resource.toString();
|
|
369
|
+
if (resource !== this.config.resource.toString())
|
|
370
|
+
throw new HostedOAuthError("Invalid resource");
|
|
371
|
+
const scope = params.get("scope") ?? "modlens";
|
|
372
|
+
if (scope !== "modlens")
|
|
373
|
+
throw new HostedOAuthError("Unsupported scope", 400, "invalid_scope");
|
|
374
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
375
|
+
const state = await this.putObject("state", { clientId, callback, resource,
|
|
376
|
+
challenge: params.get("code_challenge"), clientState: params.get("state") ?? "", verifier }, 600);
|
|
377
|
+
const upstream = new URL(this.config.authorizeUrl);
|
|
378
|
+
upstream.searchParams.set("response_type", "code");
|
|
379
|
+
upstream.searchParams.set("client_id", this.config.clientId);
|
|
380
|
+
upstream.searchParams.set("redirect_uri", this.callbackUrl());
|
|
381
|
+
upstream.searchParams.set("scope", this.config.scopes);
|
|
382
|
+
upstream.searchParams.set("state", state);
|
|
383
|
+
upstream.searchParams.set("code_challenge", challenge(verifier));
|
|
384
|
+
upstream.searchParams.set("code_challenge_method", "S256");
|
|
385
|
+
redirect(res, upstream);
|
|
386
|
+
return true;
|
|
387
|
+
}
|
|
388
|
+
if (route === "/oauth/upstream/callback" && req.method === "GET") {
|
|
389
|
+
uniqueQuery(url.searchParams);
|
|
390
|
+
const state = await this.takeObject(required(url.searchParams, "state"), "state");
|
|
391
|
+
const callback = new URL(state.callback);
|
|
392
|
+
if (url.searchParams.has("error")) {
|
|
393
|
+
callback.searchParams.set("error", url.searchParams.get("error") ?? "access_denied");
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
const reportedIssuer = url.searchParams.get("iss");
|
|
397
|
+
if ((this.config.requiresIss && !reportedIssuer) ||
|
|
398
|
+
(reportedIssuer && reportedIssuer !== this.config.issuer))
|
|
399
|
+
throw new HostedOAuthError("Provider issuer mismatch", 400, "invalid_grant");
|
|
400
|
+
const values = new URLSearchParams({ grant_type: "authorization_code", code: required(url.searchParams, "code"),
|
|
401
|
+
redirect_uri: this.callbackUrl(), code_verifier: state.verifier });
|
|
402
|
+
const upstream = await this.upstreamToken(values);
|
|
403
|
+
const profile = await this.profile(upstream.access_token);
|
|
404
|
+
if (!profile.allowed)
|
|
405
|
+
throw new HostedOAuthError("Account access denied", 403, "access_denied");
|
|
406
|
+
const approval = await this.putObject("approval", { ...state, upstream, subject: profile.subject }, 600);
|
|
407
|
+
const destination = callback.origin.replaceAll("&", "&").replaceAll("<", "<").replaceAll('"', """);
|
|
408
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store",
|
|
409
|
+
"Referrer-Policy": "no-referrer", "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'" });
|
|
410
|
+
res.end(`<!doctype html><html><meta charset="utf-8"><title>Connect to ModLens</title><body><h1>Connect to ModLens</h1><p>Allow the application at <strong>${destination}</strong> to use your hosted ModLens access?</p><form method="post" action="/oauth/approve"><input type="hidden" name="approval" value="${approval}"><button name="decision" value="allow">Allow</button><button name="decision" value="deny">Deny</button></form></body></html>`);
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
413
|
+
if (state.clientState)
|
|
414
|
+
callback.searchParams.set("state", state.clientState);
|
|
415
|
+
callback.searchParams.set("iss", this.issuerUrl());
|
|
416
|
+
redirect(res, callback);
|
|
417
|
+
return true;
|
|
418
|
+
}
|
|
419
|
+
if (route === "/oauth/approve" && req.method === "POST") {
|
|
420
|
+
const params = form(await body(req));
|
|
421
|
+
const approval = await this.takeObject(required(params, "approval"), "approval");
|
|
422
|
+
const callback = new URL(approval.callback);
|
|
423
|
+
if (params.get("decision") === "allow") {
|
|
424
|
+
const code = await this.putObject("code", approval, 60);
|
|
425
|
+
callback.searchParams.set("code", code);
|
|
426
|
+
}
|
|
427
|
+
else
|
|
428
|
+
callback.searchParams.set("error", "access_denied");
|
|
429
|
+
if (approval.clientState)
|
|
430
|
+
callback.searchParams.set("state", approval.clientState);
|
|
431
|
+
callback.searchParams.set("iss", this.issuerUrl());
|
|
432
|
+
redirect(res, callback);
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
435
|
+
if (route === "/oauth/token" && req.method === "POST") {
|
|
436
|
+
const params = form(await body(req));
|
|
437
|
+
const clientId = required(params, "client_id");
|
|
438
|
+
await this.client(clientId);
|
|
439
|
+
const grantType = required(params, "grant_type");
|
|
440
|
+
if (grantType === "authorization_code") {
|
|
441
|
+
const code = await this.takeObject(required(params, "code"), "code");
|
|
442
|
+
if (code.clientId !== clientId || code.callback !== required(params, "redirect_uri") ||
|
|
443
|
+
challenge(required(params, "code_verifier")) !== code.challenge ||
|
|
444
|
+
(params.has("resource") && params.get("resource") !== code.resource))
|
|
445
|
+
throw new HostedOAuthError("Authorization code binding failed", 400, "invalid_grant");
|
|
446
|
+
const grantId = token("mlg_");
|
|
447
|
+
const refresh = code.upstream.refresh_token ? token("mlr_") : undefined;
|
|
448
|
+
const grant = { id: grantId, client_id: clientId, subject: code.subject, resource: code.resource,
|
|
449
|
+
upstream_access: this.seal(code.upstream.access_token),
|
|
450
|
+
upstream_refresh: code.upstream.refresh_token ? this.seal(code.upstream.refresh_token) : null,
|
|
451
|
+
upstream_expires: now() + Math.max(1, Math.min(86400, Number(code.upstream.expires_in) || 300)),
|
|
452
|
+
refresh_hash: refresh ? sha(refresh) : null, refresh_expires: now() + 30 * 86400 };
|
|
453
|
+
const db = await this.db();
|
|
454
|
+
await db.$executeRawUnsafe(`INSERT INTO hosted_oauth_grants (id,client_id,subject,resource,upstream_access,upstream_refresh,
|
|
455
|
+
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);
|
|
456
|
+
json(res, 200, { ...await this.issue(grant), ...(refresh ? { refresh_token: refresh } : {}) });
|
|
457
|
+
return true;
|
|
458
|
+
}
|
|
459
|
+
if (grantType === "refresh_token") {
|
|
460
|
+
if (params.has("resource") && params.get("resource") !== this.config.resource.toString())
|
|
461
|
+
throw new HostedOAuthError("Wrong resource", 400, "invalid_grant");
|
|
462
|
+
if (params.has("scope") && params.get("scope") !== "modlens")
|
|
463
|
+
throw new HostedOAuthError("Unsupported scope", 400, "invalid_scope");
|
|
464
|
+
const raw = required(params, "refresh_token");
|
|
465
|
+
const db = await this.db();
|
|
466
|
+
const rows = await db.$queryRawUnsafe(`UPDATE hosted_oauth_grants SET refresh_hash=NULL
|
|
467
|
+
WHERE refresh_hash=$1 AND client_id=$2 AND refresh_expires>$3 RETURNING *`, sha(raw), clientId, now());
|
|
468
|
+
if (rows.length !== 1 || !rows[0].upstream_refresh)
|
|
469
|
+
throw new HostedOAuthError("Invalid refresh token", 400, "invalid_grant");
|
|
470
|
+
const grant = rows[0];
|
|
471
|
+
try {
|
|
472
|
+
const oldUpstreamRefresh = grant.upstream_refresh;
|
|
473
|
+
const upstream = await this.upstreamToken(new URLSearchParams({ grant_type: "refresh_token",
|
|
474
|
+
refresh_token: this.open(oldUpstreamRefresh) }));
|
|
475
|
+
grant.upstream_access = this.seal(upstream.access_token);
|
|
476
|
+
grant.upstream_refresh = this.seal(upstream.refresh_token ?? this.open(oldUpstreamRefresh));
|
|
477
|
+
grant.upstream_expires = now() + Math.max(1, Math.min(86400, Number(upstream.expires_in) || 300));
|
|
478
|
+
await db.$executeRawUnsafe(`UPDATE hosted_oauth_grants SET upstream_access=$2,upstream_refresh=$3,
|
|
479
|
+
upstream_expires=$4 WHERE id=$1`, grant.id, grant.upstream_access, grant.upstream_refresh, grant.upstream_expires);
|
|
480
|
+
const profile = await this.profile(upstream.access_token);
|
|
481
|
+
if (profile.subject !== grant.subject || !profile.allowed)
|
|
482
|
+
throw new HostedOAuthError("Account access denied", 403, "access_denied");
|
|
483
|
+
const next = token("mlr_");
|
|
484
|
+
grant.refresh_hash = sha(next);
|
|
485
|
+
await db.$executeRawUnsafe(`UPDATE hosted_oauth_grants SET refresh_hash=$2 WHERE id=$1`, grant.id, grant.refresh_hash);
|
|
486
|
+
json(res, 200, { ...await this.issue(grant), refresh_token: next });
|
|
487
|
+
}
|
|
488
|
+
catch (error) {
|
|
489
|
+
if (error instanceof HostedOAuthError && error.status === 503) {
|
|
490
|
+
await db.$executeRawUnsafe(`UPDATE hosted_oauth_grants SET refresh_hash=$2 WHERE id=$1 AND refresh_hash IS NULL`, grant.id, sha(raw));
|
|
491
|
+
}
|
|
492
|
+
else
|
|
493
|
+
await this.revoke(grant.id);
|
|
494
|
+
throw error;
|
|
495
|
+
}
|
|
496
|
+
return true;
|
|
497
|
+
}
|
|
498
|
+
throw new HostedOAuthError("Unsupported grant type", 400, "unsupported_grant_type");
|
|
499
|
+
}
|
|
500
|
+
if (route === "/oauth/revoke" && req.method === "POST") {
|
|
501
|
+
const params = form(await body(req));
|
|
502
|
+
const clientId = required(params, "client_id");
|
|
503
|
+
await this.client(clientId);
|
|
504
|
+
const raw = required(params, "token");
|
|
505
|
+
const db = await this.db();
|
|
506
|
+
const rows = await db.$queryRawUnsafe(`SELECT id FROM hosted_oauth_grants WHERE client_id=$1 AND refresh_hash=$2`, clientId, sha(raw));
|
|
507
|
+
if (rows.length)
|
|
508
|
+
await this.revoke(rows[0].id);
|
|
509
|
+
else {
|
|
510
|
+
const access = await db.$queryRawUnsafe(`SELECT g.id FROM hosted_oauth_access a JOIN hosted_oauth_grants g ON g.id=a.grant_id
|
|
511
|
+
WHERE g.client_id=$1 AND a.token_hash=$2`, clientId, sha(raw));
|
|
512
|
+
if (access.length)
|
|
513
|
+
await this.revoke(access[0].id);
|
|
514
|
+
}
|
|
515
|
+
json(res, 200, {});
|
|
516
|
+
return true;
|
|
517
|
+
}
|
|
518
|
+
return false;
|
|
519
|
+
}
|
|
520
|
+
metadataUrlPath() { return new URL(this.metadataUrl()).pathname; }
|
|
521
|
+
}
|
|
522
|
+
//# sourceMappingURL=hosted-oauth.js.map
|