@creeperhost/modlens-mcp 1.6.24 → 1.6.27
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 +7 -4
- package/RUNTIME.md +30 -25
- package/dist/hosted-oauth.d.ts +7 -0
- package/dist/hosted-oauth.d.ts.map +1 -1
- package/dist/hosted-oauth.js +145 -45
- package/dist/hosted-oauth.js.map +1 -1
- package/dist/hosted-tool-catalog.d.ts +11 -0
- package/dist/hosted-tool-catalog.d.ts.map +1 -0
- package/dist/hosted-tool-catalog.js +48 -0
- package/dist/hosted-tool-catalog.js.map +1 -0
- package/dist/runtime/guidance.d.ts +1 -1
- package/dist/runtime/guidance.js +1 -1
- package/dist/runtime/guidance.js.map +1 -1
- package/dist/runtime/hub.d.ts +2 -1
- package/dist/runtime/hub.d.ts.map +1 -1
- package/dist/runtime/hub.js +22 -12
- package/dist/runtime/hub.js.map +1 -1
- package/dist/runtime/modlens-agent.jar +0 -0
- package/dist/runtime/requests.d.ts +4 -4
- package/dist/runtime/requests.d.ts.map +1 -1
- package/dist/runtime/requests.js +5 -4
- package/dist/runtime/requests.js.map +1 -1
- package/dist/server.js +23 -4
- package/dist/server.js.map +1 -1
- package/package.json +4 -1
- package/scripts/build-runtime-agent.mjs +63 -3
- package/scripts/gradle/modlens-runtime.init.gradle +1 -1
- package/scripts/test-hosted-oauth.mjs +87 -9
- package/scripts/test-hosted-policy.mjs +26 -0
- package/scripts/test-package.mjs +4 -0
- package/scripts/test-runtime-glfw.mjs +81 -0
- package/scripts/test-runtime-legacy.mjs +71 -0
- package/scripts/test-runtime-lwjgl2-real.mjs +59 -0
package/README.md
CHANGED
|
@@ -6,10 +6,11 @@ Store mod metadata, class indexes, mixin targets, AT/AW entries, and decompiled
|
|
|
6
6
|
|
|
7
7
|
## Optional live development client
|
|
8
8
|
|
|
9
|
-
The `runtime` MCP tool can prepare and launch a
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
and
|
|
9
|
+
The `runtime` MCP tool can prepare and launch a Minecraft development client,
|
|
10
|
+
detect IntelliJ launches, monitor JVM failures and memory pressure, capture
|
|
11
|
+
screenshots, and control game input. It uses SDL on Minecraft 26.3, GLFW on
|
|
12
|
+
1.13–1.21, and LWJGL2 on 1.7.10–1.12.2. Ask the AI
|
|
13
|
+
to call `runtime` with `action:"help"` or set it up
|
|
13
14
|
for your mod project. With remote MCP, Codex runs the local `--runtime` helper;
|
|
14
15
|
with local stdio MCP, the tools execute directly. No second MCP connection is needed.
|
|
15
16
|
See [runtime setup, examples, and compatibility limits](RUNTIME.md).
|
|
@@ -1105,6 +1106,8 @@ npx -y @creeperhost/modlens-mcp --local-mod --request-file local-request.json
|
|
|
1105
1106
|
|
|
1106
1107
|
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.
|
|
1107
1108
|
|
|
1109
|
+
Dynamic client registration accepts the optional `client_name` metadata field. The consent page displays this self-reported name alongside the registered callback origin. Existing clients must register again to supply a name; clients without one appear as "Unnamed application".
|
|
1110
|
+
|
|
1108
1111
|
OAuth clients, short-lived authorization state, grants, and tokens are stored in the configured database. A multi-replica deployment must point every replica at the same persistent database and use the same `MODLENS_OAUTH_STORAGE_KEY`; the embedded SQLite default is suitable only for a single replica. Otherwise, a browser redirect can land on a replica that cannot see the authorization created by the previous request and fail with `invalid_grant`.
|
|
1109
1112
|
|
|
1110
1113
|
The server writes structured OAuth lifecycle events to stderr with a short `flow` identifier. Failures also include an `incident` reference shown on browser-facing error pages, so one report can be matched to its server log. These events include the stage, route, status, error code, hashed client reference, and redirect origin where relevant; authorization codes, state values, PKCE material, tokens, provider profiles, and subjects are never logged.
|
package/RUNTIME.md
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# Optional live Minecraft development runtime
|
|
2
2
|
|
|
3
|
-
ModLens can launch or detect a
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
ModLens can launch or detect a Minecraft development client and observe JVM
|
|
4
|
+
health and failures. One Java agent JAR supports Minecraft 1.7.10 and newer.
|
|
5
|
+
It hooks SDL on 26.3, GLFW on 1.13–1.21, and LWJGL2 on 1.7.10–1.12.2 for
|
|
6
|
+
input and screenshots. JFR diagnostics are available when the game runs on
|
|
7
|
+
Java 11 or newer.
|
|
8
|
+
Desktop automation and an IntelliJ plugin are not required.
|
|
7
9
|
|
|
8
10
|
## Let the AI set it up
|
|
9
11
|
|
|
@@ -18,6 +20,12 @@ The always-discoverable `runtime` MCP tool describes this workflow. The AI calls
|
|
|
18
20
|
{"action":"setup","projectDir":"F:/Git/my-mod","mcVersion":"26.3","mode":"observe","gradleTask":"runClient"}
|
|
19
21
|
```
|
|
20
22
|
|
|
23
|
+
For an older client, specify its version. For example:
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{"action":"setup","projectDir":"F:/Git/my-old-mod","mcVersion":"1.7.10","mode":"observe","gradleTask":"runClient"}
|
|
27
|
+
```
|
|
28
|
+
|
|
21
29
|
With local stdio, that tool call executes directly. With remote MCP, it returns
|
|
22
30
|
`executed:false` and a **local execution plan**. Codex writes the supplied JSON
|
|
23
31
|
request to a local file and invokes the version-matched helper through its local
|
|
@@ -38,8 +46,8 @@ node /path/to/modlens-mcp/dist/launcher.js --runtime --request-file runtime-requ
|
|
|
38
46
|
|
|
39
47
|
The helper automatically starts an authenticated loopback companion. It stays
|
|
40
48
|
running between commands, detects configured IntelliJ launches, and collects
|
|
41
|
-
events while Codex is doing other work. It requires Node.js and
|
|
42
|
-
|
|
49
|
+
events while Codex is doing other work. It requires Node.js and a JDK supported by
|
|
50
|
+
the Minecraft client (Java 8 or newer); it does not bootstrap a source database or modify
|
|
43
51
|
MCP configuration. There is no second local MCP connection, Cloudflare dependency,
|
|
44
52
|
public runtime endpoint, or automatic upload of diagnostics to remote ModLens.
|
|
45
53
|
Codex must have local terminal/file access on the Minecraft PC; a cloud-only shell
|
|
@@ -75,9 +83,9 @@ For multi-project builds, select the actual client task, e.g. `:fabric:runClient
|
|
|
75
83
|
The task must extend Gradle `JavaExec`. A generated init script adds the agent to
|
|
76
84
|
that task only. Custom launch plugins which don't expose a JavaExec task get an
|
|
77
85
|
actionable error. Setup also returns `vmOptions` for the actual game JVM in an
|
|
78
|
-
existing Application run configuration.
|
|
79
|
-
`-XX:StackShadowPages=32`:
|
|
80
|
-
agent. The generated Gradle/IntelliJ launch supplies
|
|
86
|
+
existing Application run configuration. For 26.3, add every entry, including
|
|
87
|
+
`-XX:StackShadowPages=32`: that client needs this setting independently of the
|
|
88
|
+
agent. Older versions receive only the agent VM option. The generated Gradle/IntelliJ launch supplies the required options, only to the
|
|
81
89
|
selected client task. **Do not put these options on IntelliJ itself or the Gradle
|
|
82
90
|
daemon.** Quote each whole VM option if it contains spaces. The singular `vmOption`
|
|
83
91
|
field remains available for callers that only need the agent argument.
|
|
@@ -94,7 +102,7 @@ include the compiled agent; end users do not need to compile it.
|
|
|
94
102
|
| --- | --- | --- |
|
|
95
103
|
| `interactive` | Visible game window | Human controls; MCP input rejected |
|
|
96
104
|
| `observe` | Visible game window | MCP controls; physical game input filtered |
|
|
97
|
-
| `hidden` | Hidden game window | MCP controls; physical game input filtered |
|
|
105
|
+
| `hidden` | Hidden game window (SDL/GLFW) | MCP controls; physical game input filtered |
|
|
98
106
|
|
|
99
107
|
Switch a connected client with:
|
|
100
108
|
|
|
@@ -105,7 +113,8 @@ Switch a connected client with:
|
|
|
105
113
|
Observe mode uses the actual game window as a watch-only display. OS window
|
|
106
114
|
management, including closing it, remains available. Hidden mode still uses a
|
|
107
115
|
graphics device and desktop/display environment; it is not a GPU-free server.
|
|
108
|
-
|
|
116
|
+
LWJGL2 clients support `interactive` and `observe`; their Java display API does
|
|
117
|
+
not support hidden mode. Input interception covers the normal LWJGL paths. Mods using another native input
|
|
109
118
|
path need a separate adapter. This is a development convenience, not an OS security
|
|
110
119
|
boundary. Switching to interactive returns control; click the game to resume mouse
|
|
111
120
|
capture normally.
|
|
@@ -130,7 +139,7 @@ capture normally.
|
|
|
130
139
|
```
|
|
131
140
|
|
|
132
141
|
Key names include A–Z, 0–9, SPACE, ENTER, ESCAPE, TAB, arrows, modifiers and F1–F12.
|
|
133
|
-
Numeric strings outside single digits represent
|
|
142
|
+
Numeric strings outside single digits represent backend-specific key codes. Mouse buttons are
|
|
134
143
|
1=left, 2=middle, 3=right. Coordinates are **window pixels**, not scaled GUI units;
|
|
135
144
|
relative movements are deltas. Text sends text-input events independently of keys.
|
|
136
145
|
`scroll` accepts `x` and `y`. Inputs are delivered to the event loop; they do not
|
|
@@ -142,8 +151,8 @@ been unreachable for five seconds (as soon as the game event loop runs). Use
|
|
|
142
151
|
automatically after uncertain delivery. A timeout means execution is unknown;
|
|
143
152
|
inspect state before retrying an action.
|
|
144
153
|
|
|
145
|
-
|
|
146
|
-
`state.observation.gameLoaded` becomes true. PNGs are returned as MCP image content
|
|
154
|
+
On 26.3, screenshots use Minecraft's screenshot API after
|
|
155
|
+
`state.observation.gameLoaded` becomes true. GLFW and LWJGL2 clients capture the OpenGL framebuffer. PNGs are returned as MCP image content
|
|
147
156
|
by `artifact` over local stdio MCP. The CLI returns the local PNG path for Codex's
|
|
148
157
|
image viewer instead of base64 in terminal text. `threads` writes a thread dump including detected deadlock IDs;
|
|
149
158
|
`recording` writes the recent JFR recording. Files live under
|
|
@@ -152,8 +161,8 @@ contain application data; delete old session directories when finished.
|
|
|
152
161
|
|
|
153
162
|
## Monitoring and failure semantics
|
|
154
163
|
|
|
155
|
-
The agent samples heap/non-heap usage, thread counts and GC counters every second
|
|
156
|
-
keeps a bounded two-minute/16 MiB JFR recording
|
|
164
|
+
The agent samples heap/non-heap usage, thread counts and GC counters every second.
|
|
165
|
+
On Java 11+, it keeps a bounded two-minute/16 MiB JFR recording. It captures uncaught exceptions while
|
|
157
166
|
chaining the previous handler, and hooks the game's fatal crash reporting path.
|
|
158
167
|
It does not intercept every logged/caught exception or exceptions consumed by a
|
|
159
168
|
custom per-thread handler. The optional Minecraft crash hook covers the normal
|
|
@@ -203,15 +212,11 @@ not guarantee an idle Codex task wakes up; host scheduling is separate from MCP.
|
|
|
203
212
|
runtime actions on the Minecraft PC using the same request schema and dispatcher
|
|
204
213
|
as local stdio MCP. HTTP handlers never execute user-supplied local paths.
|
|
205
214
|
- The agent connects only to authenticated loopback HTTP; no browser control routes.
|
|
206
|
-
- SDL events, keyboard state and mouse state are kept consistent
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
- Setup accepts `minecraftHooks:false` to isolate compatibility problems. SDL
|
|
212
|
-
controls and JVM diagnostics remain available; screenshots and structured game
|
|
213
|
-
state require the Minecraft adapter. A GLFW backend belongs to a future backport:
|
|
214
|
-
vanilla 26.3 uses SDL3.
|
|
215
|
+
- SDL events, keyboard state and mouse state are kept consistent by `SdlAdapter`;
|
|
216
|
+
optional 26.3 game APIs live in `Minecraft263`. GLFW and LWJGL2 input use
|
|
217
|
+
`LegacyInput` and a relocated ASM transformer.
|
|
218
|
+
- Setup accepts `minecraftHooks:false` for the optional 26.3 game hooks. SDL
|
|
219
|
+
control and JVM diagnostics remain available.
|
|
215
220
|
- Capability presence and validation are distinct. Tests cover selected environments;
|
|
216
221
|
Fabric/NeoForge launchers and rendering replacements still require their own runs.
|
|
217
222
|
|
package/dist/hosted-oauth.d.ts
CHANGED
|
@@ -11,10 +11,15 @@ export declare class HostedOAuth {
|
|
|
11
11
|
private config;
|
|
12
12
|
private database;
|
|
13
13
|
private ready?;
|
|
14
|
+
private readonly instance;
|
|
14
15
|
private requestCounts;
|
|
16
|
+
private refreshes;
|
|
15
17
|
private constructor();
|
|
16
18
|
private audit;
|
|
17
19
|
private clientRef;
|
|
20
|
+
private grantRef;
|
|
21
|
+
private observeTokenResponse;
|
|
22
|
+
private describeTokenResponse;
|
|
18
23
|
logError(method: string | undefined, route: string, error: HostedOAuthError): string;
|
|
19
24
|
static create(env?: NodeJS.ProcessEnv, database?: () => Promise<Database>): Promise<HostedOAuth>;
|
|
20
25
|
private db;
|
|
@@ -25,6 +30,7 @@ export declare class HostedOAuth {
|
|
|
25
30
|
private getObject;
|
|
26
31
|
private takeObject;
|
|
27
32
|
private client;
|
|
33
|
+
private clientName;
|
|
28
34
|
private upstreamToken;
|
|
29
35
|
private profile;
|
|
30
36
|
private callbackUrl;
|
|
@@ -35,6 +41,7 @@ export declare class HostedOAuth {
|
|
|
35
41
|
authenticate(req: IncomingMessage): Promise<string>;
|
|
36
42
|
private revoke;
|
|
37
43
|
private issue;
|
|
44
|
+
private refresh;
|
|
38
45
|
private limit;
|
|
39
46
|
handle(req: IncomingMessage, res: ServerResponse, url: URL): Promise<boolean>;
|
|
40
47
|
private metadataUrlPath;
|
|
@@ -1 +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;IAA6B,MAAM,CAAC,EAAE,MAAM;gBAA7F,OAAO,EAAE,MAAM,EAAS,MAAM,SAAM,EAAS,IAAI,SAAoB,EAAS,MAAM,CAAC,EAAE,MAAM,YAAA;CAC5G;
|
|
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;IAA6B,MAAM,CAAC,EAAE,MAAM;gBAA7F,OAAO,EAAE,MAAM,EAAS,MAAM,SAAM,EAAS,IAAI,SAAoB,EAAS,MAAM,CAAC,EAAE,MAAM,YAAA;CAC5G;AA8JD,qBAAa,WAAW;IAKA,OAAO,CAAC,MAAM;IAAc,OAAO,CAAC,QAAQ;IAJhE,OAAO,CAAC,KAAK,CAAC,CAAgB;IAC9B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAkC;IAC3D,OAAO,CAAC,aAAa,CAAwD;IAC7E,OAAO,CAAC,SAAS,CAAuD;IACxE,OAAO;IAEP,OAAO,CAAC,KAAK;IAIb,OAAO,CAAC,SAAS;IACjB,OAAO,CAAC,QAAQ;IAEhB,OAAO,CAAC,oBAAoB;IAoB5B,OAAO,CAAC,qBAAqB;IAY7B,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,gBAAgB,GAAG,MAAM;WAOvE,MAAM,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,EAAE,QAAQ,GAAE,MAAM,OAAO,CAAC,QAAQ,CAAS,GAAG,OAAO,CAAC,WAAW,CAAC;YAqC5G,EAAE;IAoBhB,OAAO,CAAC,IAAI;IAOZ,OAAO,CAAC,IAAI;YAOE,SAAS;YAST,WAAW;YAOX,SAAS;YAOT,UAAU;YAWV,MAAM;YAQN,UAAU;YAOV,aAAa;YAsBb,OAAO;IAiCrB,OAAO,CAAC,WAAW;IACnB,OAAO,CAAC,SAAS;IACjB,OAAO,CAAC,WAAW;IAInB,SAAS,CAAC,GAAG,EAAE,cAAc,GAAG,IAAI;IAIpC,YAAY,CAAC,GAAG,EAAE,cAAc,EAAE,KAAK,EAAE,gBAAgB,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI;IAa7E,YAAY,CAAC,GAAG,EAAE,eAAe,GAAG,OAAO,CAAC,MAAM,CAAC;YA6B3C,MAAM;YAON,KAAK;YASL,OAAO;IAiDrB,OAAO,CAAC,KAAK;IAYP,MAAM,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,EAAE,GAAG,EAAE,GAAG,GAAG,OAAO,CAAC,OAAO,CAAC;IA2OnF,OAAO,CAAC,eAAe;CAC1B"}
|
package/dist/hosted-oauth.js
CHANGED
|
@@ -15,19 +15,20 @@ const sha = (value) => createHash("sha256").update(value).digest("hex");
|
|
|
15
15
|
const token = (prefix) => prefix + randomBytes(32).toString("base64url");
|
|
16
16
|
const now = () => Math.floor(Date.now() / 1000);
|
|
17
17
|
const json = (res, status, value, headers = {}) => {
|
|
18
|
-
res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store", ...headers });
|
|
18
|
+
res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store", Pragma: "no-cache", ...headers });
|
|
19
19
|
res.end(JSON.stringify(value));
|
|
20
20
|
};
|
|
21
21
|
const redirect = (res, url) => {
|
|
22
22
|
res.writeHead(302, { Location: url.toString(), "Cache-Control": "no-store" });
|
|
23
23
|
res.end();
|
|
24
24
|
};
|
|
25
|
+
const htmlCsp = (formAction = "'self'") => `default-src 'none'; style-src 'unsafe-inline'; form-action ${formAction}; base-uri 'none'; frame-ancestors 'none'`;
|
|
25
26
|
const htmlHeaders = {
|
|
26
27
|
"Content-Type": "text/html; charset=utf-8",
|
|
27
28
|
"Cache-Control": "no-store",
|
|
28
29
|
"Referrer-Policy": "no-referrer",
|
|
29
30
|
"X-Content-Type-Options": "nosniff",
|
|
30
|
-
"Content-Security-Policy":
|
|
31
|
+
"Content-Security-Policy": htmlCsp(),
|
|
31
32
|
};
|
|
32
33
|
const escapeHtml = (value) => value.replaceAll("&", "&").replaceAll("<", "<")
|
|
33
34
|
.replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
@@ -51,7 +52,8 @@ main{width:100%;display:flex;flex:1;flex-direction:column;align-items:center;jus
|
|
|
51
52
|
.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
53
|
.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
54
|
.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-
|
|
55
|
+
.app{display:flex;align-items:center;gap:12px;margin:20px 0 12px;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-name{display:block;color:#f0f0f5;font-size:14px}.app-label{display:block;color:var(--muted);font-size:11px}
|
|
56
|
+
.connection-details{color:var(--muted);font-size:12px}.connection-details summary{width:max-content;max-width:100%;cursor:pointer;color:#d7dbe0}.connection-details summary:focus-visible{outline:2px solid var(--focus);outline-offset:3px}.connection-details p{margin:10px 0 6px}.callback-url{display:block;overflow-wrap:anywhere;color:#d7dbe0;font:500 12px/1.5 Consolas,monospace}
|
|
55
57
|
.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
58
|
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
59
|
.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}
|
|
@@ -176,15 +178,48 @@ export class HostedOAuth {
|
|
|
176
178
|
config;
|
|
177
179
|
database;
|
|
178
180
|
ready;
|
|
181
|
+
instance = randomBytes(6).toString("hex");
|
|
179
182
|
requestCounts = new Map();
|
|
183
|
+
refreshes = new Map();
|
|
180
184
|
constructor(config, database) {
|
|
181
185
|
this.config = config;
|
|
182
186
|
this.database = database;
|
|
183
187
|
}
|
|
184
188
|
audit(event, fields = {}) {
|
|
185
|
-
console.error(`[modlens] oauth ${JSON.stringify({ event, ...fields })}`);
|
|
189
|
+
console.error(`[modlens] oauth ${JSON.stringify({ event, instance: this.instance, ...fields })}`);
|
|
186
190
|
}
|
|
187
191
|
clientRef(clientId) { return sha(clientId).slice(0, 12); }
|
|
192
|
+
grantRef(grantId) { return sha(grantId).slice(0, 12); }
|
|
193
|
+
observeTokenResponse(req, res, fields) {
|
|
194
|
+
const startedAt = performance.now();
|
|
195
|
+
let finished = false;
|
|
196
|
+
req.once("aborted", () => this.audit("token_request_aborted", fields));
|
|
197
|
+
res.once("finish", () => {
|
|
198
|
+
finished = true;
|
|
199
|
+
this.audit("token_response_finished", { ...fields, status: res.statusCode,
|
|
200
|
+
outcome: res.statusCode >= 400 ? "error" : fields.outcome,
|
|
201
|
+
durationMs: Math.round(performance.now() - startedAt) });
|
|
202
|
+
});
|
|
203
|
+
res.once("close", () => {
|
|
204
|
+
if (!finished)
|
|
205
|
+
this.audit("token_response_closed", { ...fields, status: res.statusCode,
|
|
206
|
+
headersSent: res.headersSent, writableEnded: res.writableEnded, writableFinished: res.writableFinished,
|
|
207
|
+
durationMs: Math.round(performance.now() - startedAt) });
|
|
208
|
+
});
|
|
209
|
+
res.once("error", error => this.audit("token_response_error", { ...fields, status: res.statusCode,
|
|
210
|
+
error: error.name, errorCode: error.code }));
|
|
211
|
+
}
|
|
212
|
+
describeTokenResponse(response) {
|
|
213
|
+
const access = typeof response.access_token === "string" ? response.access_token : undefined;
|
|
214
|
+
const refresh = typeof response.refresh_token === "string" ? response.refresh_token : undefined;
|
|
215
|
+
return {
|
|
216
|
+
outcome: "success", responseBytes: Buffer.byteLength(JSON.stringify(response)),
|
|
217
|
+
returnedRefresh: refresh !== undefined,
|
|
218
|
+
...(access ? { accessRef: sha(access).slice(0, 12) } : {}),
|
|
219
|
+
...(refresh ? { returnedRefreshRef: sha(refresh).slice(0, 12) } : {}),
|
|
220
|
+
...(typeof response.expires_in === "number" ? { accessExpiresIn: response.expires_in } : {}),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
188
223
|
logError(method, route, error) {
|
|
189
224
|
const incident = randomBytes(6).toString("hex");
|
|
190
225
|
this.audit("request_failed", { incident, flow: error.flowId, method: method ?? "UNKNOWN", route,
|
|
@@ -236,6 +271,8 @@ export class HostedOAuth {
|
|
|
236
271
|
this.ready = (async () => {
|
|
237
272
|
await db.$executeRawUnsafe(`CREATE TABLE IF NOT EXISTS hosted_oauth_clients (
|
|
238
273
|
id TEXT PRIMARY KEY, redirect_uris TEXT NOT NULL, created BIGINT NOT NULL)`);
|
|
274
|
+
await db.$executeRawUnsafe(`CREATE TABLE IF NOT EXISTS hosted_oauth_client_metadata (
|
|
275
|
+
id TEXT PRIMARY KEY, client_name TEXT NOT NULL)`);
|
|
239
276
|
await db.$executeRawUnsafe(`CREATE TABLE IF NOT EXISTS hosted_oauth_objects (
|
|
240
277
|
id TEXT PRIMARY KEY, kind TEXT NOT NULL, payload TEXT NOT NULL, expires BIGINT NOT NULL)`);
|
|
241
278
|
await db.$executeRawUnsafe(`CREATE TABLE IF NOT EXISTS hosted_oauth_grants (
|
|
@@ -295,6 +332,11 @@ export class HostedOAuth {
|
|
|
295
332
|
throw new HostedOAuthError("Unknown OAuth client", 400, "invalid_client");
|
|
296
333
|
return JSON.parse(rows[0].redirect_uris);
|
|
297
334
|
}
|
|
335
|
+
async clientName(clientId) {
|
|
336
|
+
const db = await this.db();
|
|
337
|
+
const rows = await db.$queryRawUnsafe(`SELECT client_name FROM hosted_oauth_client_metadata WHERE id=$1`, clientId);
|
|
338
|
+
return rows[0]?.client_name;
|
|
339
|
+
}
|
|
298
340
|
async upstreamToken(values) {
|
|
299
341
|
const headers = { "Content-Type": "application/x-www-form-urlencoded" };
|
|
300
342
|
values.set("client_id", this.config.clientId);
|
|
@@ -406,21 +448,22 @@ export class HostedOAuth {
|
|
|
406
448
|
try {
|
|
407
449
|
const profile = await this.profile(this.open(grant.upstream_access));
|
|
408
450
|
if (!profile.allowed || profile.subject !== grant.subject) {
|
|
409
|
-
await this.revoke(grant.id);
|
|
451
|
+
await this.revoke(grant.id, "profile_access_denied");
|
|
410
452
|
throw new HostedOAuthError("Account access denied", 403, "access_denied");
|
|
411
453
|
}
|
|
412
454
|
}
|
|
413
455
|
catch (error) {
|
|
414
456
|
if (error instanceof HostedOAuthError && error.status === 401)
|
|
415
|
-
await this.revoke(grant.id);
|
|
457
|
+
await this.revoke(grant.id, "profile_unauthorized");
|
|
416
458
|
throw error;
|
|
417
459
|
}
|
|
418
460
|
return sha(this.config.issuer + "\0" + grant.subject);
|
|
419
461
|
}
|
|
420
|
-
async revoke(id) {
|
|
462
|
+
async revoke(id, reason) {
|
|
421
463
|
const db = await this.db();
|
|
422
464
|
await db.$executeRawUnsafe(`DELETE FROM hosted_oauth_access WHERE grant_id=$1`, id);
|
|
423
465
|
await db.$executeRawUnsafe(`DELETE FROM hosted_oauth_grants WHERE id=$1`, id);
|
|
466
|
+
this.audit("grant_revoked", { grant: this.grantRef(id), reason });
|
|
424
467
|
}
|
|
425
468
|
async issue(grant) {
|
|
426
469
|
const rawAccess = token("mla_");
|
|
@@ -429,6 +472,57 @@ export class HostedOAuth {
|
|
|
429
472
|
await db.$executeRawUnsafe(`INSERT INTO hosted_oauth_access (token_hash,grant_id,expires) VALUES ($1,$2,$3)`, sha(rawAccess), grant.id, now() + expires);
|
|
430
473
|
return { access_token: rawAccess, token_type: "Bearer", expires_in: expires, scope: "modlens" };
|
|
431
474
|
}
|
|
475
|
+
async refresh(clientId, raw, requestRef) {
|
|
476
|
+
const db = await this.db();
|
|
477
|
+
const rows = await db.$queryRawUnsafe(`UPDATE hosted_oauth_grants SET refresh_hash=NULL
|
|
478
|
+
WHERE refresh_hash=$1 AND client_id=$2 AND refresh_expires>$3 RETURNING *`, sha(raw), clientId, now());
|
|
479
|
+
if (rows.length !== 1 || !rows[0].upstream_refresh) {
|
|
480
|
+
this.audit("refresh_rejected", { request: requestRef, client: this.clientRef(clientId), refreshRef: sha(raw).slice(0, 12),
|
|
481
|
+
reason: rows.length === 1 ? "no_upstream_refresh" : "not_current_or_missing" });
|
|
482
|
+
throw new HostedOAuthError("Invalid refresh token", 400, "invalid_grant");
|
|
483
|
+
}
|
|
484
|
+
const grant = rows[0];
|
|
485
|
+
let stage = "provider_token";
|
|
486
|
+
try {
|
|
487
|
+
const oldUpstreamRefresh = grant.upstream_refresh;
|
|
488
|
+
const upstream = await this.upstreamToken(new URLSearchParams({ grant_type: "refresh_token",
|
|
489
|
+
refresh_token: this.open(oldUpstreamRefresh) }));
|
|
490
|
+
grant.upstream_access = this.seal(upstream.access_token);
|
|
491
|
+
grant.upstream_refresh = this.seal(upstream.refresh_token ?? this.open(oldUpstreamRefresh));
|
|
492
|
+
grant.upstream_expires = now() + Math.max(1, Math.min(86400, Number(upstream.expires_in) || 300));
|
|
493
|
+
stage = "grant_update";
|
|
494
|
+
await db.$executeRawUnsafe(`UPDATE hosted_oauth_grants SET upstream_access=$2,upstream_refresh=$3,
|
|
495
|
+
upstream_expires=$4 WHERE id=$1`, grant.id, grant.upstream_access, grant.upstream_refresh, grant.upstream_expires);
|
|
496
|
+
stage = "profile";
|
|
497
|
+
const profile = await this.profile(upstream.access_token);
|
|
498
|
+
if (profile.subject !== grant.subject || !profile.allowed)
|
|
499
|
+
throw new HostedOAuthError("Account access denied", 403, "access_denied");
|
|
500
|
+
const next = token("mlr_");
|
|
501
|
+
grant.refresh_hash = sha(next);
|
|
502
|
+
stage = "rotation";
|
|
503
|
+
await db.$executeRawUnsafe(`UPDATE hosted_oauth_grants SET refresh_hash=$2 WHERE id=$1`, grant.id, grant.refresh_hash);
|
|
504
|
+
stage = "access_issue";
|
|
505
|
+
const access = await this.issue(grant);
|
|
506
|
+
const response = { ...access, refresh_token: next };
|
|
507
|
+
this.audit("refresh_issued", { request: requestRef, client: this.clientRef(clientId), grant: this.grantRef(grant.id),
|
|
508
|
+
previousRefreshRef: sha(raw).slice(0, 12), refreshRef: sha(next).slice(0, 12),
|
|
509
|
+
accessExpiresIn: Number(access.expires_in) });
|
|
510
|
+
return response;
|
|
511
|
+
}
|
|
512
|
+
catch (error) {
|
|
513
|
+
const retryable = error instanceof HostedOAuthError && error.status === 503;
|
|
514
|
+
this.audit("refresh_failed", { request: requestRef, client: this.clientRef(clientId), grant: this.grantRef(grant.id),
|
|
515
|
+
refreshRef: sha(raw).slice(0, 12), stage, retryable,
|
|
516
|
+
status: error instanceof HostedOAuthError ? error.status : undefined,
|
|
517
|
+
code: error instanceof HostedOAuthError ? error.code : undefined });
|
|
518
|
+
if (retryable) {
|
|
519
|
+
await db.$executeRawUnsafe(`UPDATE hosted_oauth_grants SET refresh_hash=$2 WHERE id=$1 AND refresh_hash IS NULL`, grant.id, sha(raw));
|
|
520
|
+
}
|
|
521
|
+
else
|
|
522
|
+
await this.revoke(grant.id, "refresh_failed");
|
|
523
|
+
throw error;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
432
526
|
limit(req, route, maximum) {
|
|
433
527
|
const minute = Math.floor(now() / 60);
|
|
434
528
|
const key = `${route}:${req.socket.remoteAddress ?? "unknown"}`;
|
|
@@ -477,12 +571,19 @@ export class HostedOAuth {
|
|
|
477
571
|
!redirects.every(value => typeof value === "string" && callbackSafe(value)) ||
|
|
478
572
|
(registration.token_endpoint_auth_method && registration.token_endpoint_auth_method !== "none"))
|
|
479
573
|
throw new HostedOAuthError("Invalid client registration");
|
|
574
|
+
const clientName = registration.client_name;
|
|
575
|
+
if (clientName !== undefined && (typeof clientName !== "string" || !clientName.trim() ||
|
|
576
|
+
clientName.length > 120 || /[\x00-\x1f\x7f]/.test(clientName)))
|
|
577
|
+
throw new HostedOAuthError("Invalid client name");
|
|
480
578
|
const id = token("mlc_");
|
|
481
579
|
const db = await this.db();
|
|
482
580
|
await db.$executeRawUnsafe(`INSERT INTO hosted_oauth_clients (id,redirect_uris,created) VALUES ($1,$2,$3)`, id, JSON.stringify(redirects), now());
|
|
581
|
+
if (clientName !== undefined)
|
|
582
|
+
await db.$executeRawUnsafe(`INSERT INTO hosted_oauth_client_metadata (id,client_name) VALUES ($1,$2)`, id, clientName);
|
|
483
583
|
this.audit("client_registered", { client: this.clientRef(id), redirects: redirects.length });
|
|
484
584
|
json(res, 201, { client_id: id, redirect_uris: redirects, grant_types: ["authorization_code", "refresh_token"],
|
|
485
|
-
response_types: ["code"], token_endpoint_auth_method: "none"
|
|
585
|
+
response_types: ["code"], token_endpoint_auth_method: "none",
|
|
586
|
+
...(clientName === undefined ? {} : { client_name: clientName }) });
|
|
486
587
|
return true;
|
|
487
588
|
}
|
|
488
589
|
if (route === "/oauth/authorize" && req.method === "GET") {
|
|
@@ -543,13 +644,17 @@ export class HostedOAuth {
|
|
|
543
644
|
const approval = await this.putObject("approval", { ...state, upstream, subject: profile.subject }, 600);
|
|
544
645
|
this.audit("consent_presented", { flow: state.flowId, client: this.clientRef(state.clientId),
|
|
545
646
|
redirectOrigin: callback.origin });
|
|
546
|
-
const
|
|
647
|
+
const clientName = await this.clientName(state.clientId);
|
|
648
|
+
const destination = escapeHtml(callback.toString());
|
|
547
649
|
const content = `<h1>Allow access?</h1><p>The application below wants to connect to your hosted ModLens account.</p>`
|
|
548
|
-
+ `<div class="app"><span class="app-icon" aria-hidden="true">>_</span><span class="app-copy"><strong class="app-
|
|
650
|
+
+ `<div class="app"><span class="app-icon" aria-hidden="true">>_</span><span class="app-copy"><strong class="app-name">${clientName ? escapeHtml(clientName) : "Unnamed application"}</strong><small class="app-label">${clientName ? "Name supplied by application" : "No application name supplied"}</small></span></div>`
|
|
651
|
+
+ `<details class="connection-details"><summary>Connection details</summary><p>After approval, ModLens returns you to this registered callback:</p><code class="callback-url">${destination}</code></details>`
|
|
549
652
|
+ `<ul class="permissions"><li>Run hosted ModLens tools on your behalf</li><li>Use your hosted account allowance</li></ul>`
|
|
550
653
|
+ `<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>`
|
|
551
654
|
+ `<p class="note">Only continue if you started this request in the app.</p>`;
|
|
552
|
-
|
|
655
|
+
// Browsers may apply form-action to the redirect after approval as well as the POST.
|
|
656
|
+
res.writeHead(200, { ...htmlHeaders,
|
|
657
|
+
"Content-Security-Policy": htmlCsp(`'self' ${callback.origin}`) });
|
|
553
658
|
res.end(page("Connect", content));
|
|
554
659
|
return true;
|
|
555
660
|
}
|
|
@@ -592,10 +697,16 @@ export class HostedOAuth {
|
|
|
592
697
|
return true;
|
|
593
698
|
}
|
|
594
699
|
if (route === "/oauth/token" && req.method === "POST") {
|
|
700
|
+
const requestRef = randomBytes(6).toString("hex");
|
|
701
|
+
const responseAudit = { request: requestRef };
|
|
702
|
+
this.observeTokenResponse(req, res, responseAudit);
|
|
595
703
|
const params = form(await body(req));
|
|
596
704
|
const clientId = required(params, "client_id");
|
|
705
|
+
responseAudit.client = this.clientRef(clientId);
|
|
597
706
|
await this.client(clientId);
|
|
598
707
|
const grantType = required(params, "grant_type");
|
|
708
|
+
responseAudit.grantType = grantType;
|
|
709
|
+
this.audit("token_request_received", { request: requestRef, client: responseAudit.client, grantType });
|
|
599
710
|
if (grantType === "authorization_code") {
|
|
600
711
|
const code = await this.takeObject(required(params, "code"), "code");
|
|
601
712
|
if (code.clientId !== clientId || code.callback !== required(params, "redirect_uri") ||
|
|
@@ -612,8 +723,12 @@ export class HostedOAuth {
|
|
|
612
723
|
const db = await this.db();
|
|
613
724
|
await db.$executeRawUnsafe(`INSERT INTO hosted_oauth_grants (id,client_id,subject,resource,upstream_access,upstream_refresh,
|
|
614
725
|
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);
|
|
615
|
-
const
|
|
616
|
-
|
|
726
|
+
const access = await this.issue(grant);
|
|
727
|
+
const response = { ...access, ...(refresh ? { refresh_token: refresh } : {}) };
|
|
728
|
+
this.audit("token_issued", { request: requestRef, flow: code.flowId, client: this.clientRef(clientId), refresh: !!refresh,
|
|
729
|
+
grant: this.grantRef(grant.id), accessExpiresIn: Number(access.expires_in),
|
|
730
|
+
...(refresh ? { refreshRef: sha(refresh).slice(0, 12) } : {}) });
|
|
731
|
+
Object.assign(responseAudit, this.describeTokenResponse(response));
|
|
617
732
|
json(res, 200, response);
|
|
618
733
|
return true;
|
|
619
734
|
}
|
|
@@ -623,37 +738,22 @@ export class HostedOAuth {
|
|
|
623
738
|
if (params.has("scope") && params.get("scope") !== "modlens")
|
|
624
739
|
throw new HostedOAuthError("Unsupported scope", 400, "invalid_scope");
|
|
625
740
|
const raw = required(params, "refresh_token");
|
|
626
|
-
|
|
627
|
-
const
|
|
628
|
-
|
|
629
|
-
if (
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
refresh_token: this.open(oldUpstreamRefresh) }));
|
|
636
|
-
grant.upstream_access = this.seal(upstream.access_token);
|
|
637
|
-
grant.upstream_refresh = this.seal(upstream.refresh_token ?? this.open(oldUpstreamRefresh));
|
|
638
|
-
grant.upstream_expires = now() + Math.max(1, Math.min(86400, Number(upstream.expires_in) || 300));
|
|
639
|
-
await db.$executeRawUnsafe(`UPDATE hosted_oauth_grants SET upstream_access=$2,upstream_refresh=$3,
|
|
640
|
-
upstream_expires=$4 WHERE id=$1`, grant.id, grant.upstream_access, grant.upstream_refresh, grant.upstream_expires);
|
|
641
|
-
const profile = await this.profile(upstream.access_token);
|
|
642
|
-
if (profile.subject !== grant.subject || !profile.allowed)
|
|
643
|
-
throw new HostedOAuthError("Account access denied", 403, "access_denied");
|
|
644
|
-
const next = token("mlr_");
|
|
645
|
-
grant.refresh_hash = sha(next);
|
|
646
|
-
await db.$executeRawUnsafe(`UPDATE hosted_oauth_grants SET refresh_hash=$2 WHERE id=$1`, grant.id, grant.refresh_hash);
|
|
647
|
-
json(res, 200, { ...await this.issue(grant), refresh_token: next });
|
|
648
|
-
}
|
|
649
|
-
catch (error) {
|
|
650
|
-
if (error instanceof HostedOAuthError && error.status === 503) {
|
|
651
|
-
await db.$executeRawUnsafe(`UPDATE hosted_oauth_grants SET refresh_hash=$2 WHERE id=$1 AND refresh_hash IS NULL`, grant.id, sha(raw));
|
|
652
|
-
}
|
|
653
|
-
else
|
|
654
|
-
await this.revoke(grant.id);
|
|
655
|
-
throw error;
|
|
741
|
+
responseAudit.presentedRefreshRef = sha(raw).slice(0, 12);
|
|
742
|
+
const key = `${clientId}:${sha(raw)}`;
|
|
743
|
+
let pending = this.refreshes.get(key);
|
|
744
|
+
if (!pending) {
|
|
745
|
+
this.audit("refresh_started", { request: requestRef, client: this.clientRef(clientId),
|
|
746
|
+
refreshRef: responseAudit.presentedRefreshRef });
|
|
747
|
+
pending = this.refresh(clientId, raw, requestRef);
|
|
748
|
+
this.refreshes.set(key, pending);
|
|
749
|
+
void pending.finally(() => this.refreshes.delete(key)).catch(() => { });
|
|
656
750
|
}
|
|
751
|
+
else
|
|
752
|
+
this.audit("refresh_joined", { request: requestRef, client: this.clientRef(clientId),
|
|
753
|
+
refreshRef: responseAudit.presentedRefreshRef });
|
|
754
|
+
const response = await pending;
|
|
755
|
+
Object.assign(responseAudit, this.describeTokenResponse(response));
|
|
756
|
+
json(res, 200, response);
|
|
657
757
|
return true;
|
|
658
758
|
}
|
|
659
759
|
throw new HostedOAuthError("Unsupported grant type", 400, "unsupported_grant_type");
|
|
@@ -666,12 +766,12 @@ export class HostedOAuth {
|
|
|
666
766
|
const db = await this.db();
|
|
667
767
|
const rows = await db.$queryRawUnsafe(`SELECT id FROM hosted_oauth_grants WHERE client_id=$1 AND refresh_hash=$2`, clientId, sha(raw));
|
|
668
768
|
if (rows.length)
|
|
669
|
-
await this.revoke(rows[0].id);
|
|
769
|
+
await this.revoke(rows[0].id, "client_request");
|
|
670
770
|
else {
|
|
671
771
|
const access = await db.$queryRawUnsafe(`SELECT g.id FROM hosted_oauth_access a JOIN hosted_oauth_grants g ON g.id=a.grant_id
|
|
672
772
|
WHERE g.client_id=$1 AND a.token_hash=$2`, clientId, sha(raw));
|
|
673
773
|
if (access.length)
|
|
674
|
-
await this.revoke(access[0].id);
|
|
774
|
+
await this.revoke(access[0].id, "client_request");
|
|
675
775
|
}
|
|
676
776
|
json(res, 200, {});
|
|
677
777
|
return true;
|