@creeperhost/modlens-mcp 1.6.23 → 1.6.26
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 +14 -6
- package/RUNTIME.md +30 -25
- package/dist/hosted-oauth.d.ts +5 -0
- package/dist/hosted-oauth.d.ts.map +1 -1
- package/dist/hosted-oauth.js +156 -50
- 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/private-network.d.ts +4 -0
- package/dist/private-network.d.ts.map +1 -0
- package/dist/private-network.js +17 -0
- package/dist/private-network.js.map +1 -0
- package/dist/runtime/companion.d.ts +3 -3
- 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 +29 -10
- 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 +75 -12
- 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).
|
|
@@ -1067,12 +1068,17 @@ node dist/cli.js check-updates 2
|
|
|
1067
1068
|
|
|
1068
1069
|
## Hosted access limits
|
|
1069
1070
|
|
|
1070
|
-
HTTP MCP (`MCP_PORT`) enables hosted limits by default; local stdio retains full access.
|
|
1071
|
+
HTTP MCP (`MCP_PORT`) enables hosted limits by default; local stdio retains full access. Direct HTTP connections from RFC 1918 IPv4 peers (`10/8`, `172.16/12`, `192.168/16`) also get full tool access by default, including whole-mod and whole-Minecraft decompilation, without gateway or OAuth authentication. Set `MODLENS_RFC1918_BYPASS=0` to require normal hosted authentication and limits for these clients. Loopback and IPv6 addresses do not match this bypass.
|
|
1071
1072
|
|
|
1072
|
-
The
|
|
1073
|
+
The check uses the TCP peer address, including IPv4-mapped IPv6 addresses, and ignores claimed client IP headers. Requests carrying `Forwarded`, `X-Forwarded-For`, `X-Real-IP`, or ModLens gateway identity headers follow normal hosted access. If a reverse proxy reaches the server from an RFC 1918 address, configure it to send a forwarding header for **every** request or disable the bypass; otherwise its public clients may appear to be private peers. Private clients must connect directly to use this allowance.
|
|
1074
|
+
|
|
1075
|
+
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.
|
|
1076
|
+
|
|
1077
|
+
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 unavailable to public hosted clients.
|
|
1073
1078
|
|
|
1074
1079
|
| Setting | Default | Scope |
|
|
1075
1080
|
| --- | --- | --- |
|
|
1081
|
+
| `MODLENS_RFC1918_BYPASS` | enabled | Set to `0` to disable private-peer full access |
|
|
1076
1082
|
| `MODLENS_HOSTED_SOURCE_LINES` | 200 | Source/bytecode lines per response, shared across search snippets |
|
|
1077
1083
|
| `MODLENS_HOSTED_RESPONSE_BYTES` | 131072 (128 KiB) | Serialized text content per tool response |
|
|
1078
1084
|
| `MODLENS_HOSTED_DAILY_BYTES` | 5242880 (5 MiB) | Delivered tool content per account per UTC day |
|
|
@@ -1100,6 +1106,8 @@ npx -y @creeperhost/modlens-mcp --local-mod --request-file local-request.json
|
|
|
1100
1106
|
|
|
1101
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.
|
|
1102
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
|
+
|
|
1103
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`.
|
|
1104
1112
|
|
|
1105
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,13 @@ 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;
|
|
18
21
|
logError(method: string | undefined, route: string, error: HostedOAuthError): string;
|
|
19
22
|
static create(env?: NodeJS.ProcessEnv, database?: () => Promise<Database>): Promise<HostedOAuth>;
|
|
20
23
|
private db;
|
|
@@ -25,6 +28,7 @@ export declare class HostedOAuth {
|
|
|
25
28
|
private getObject;
|
|
26
29
|
private takeObject;
|
|
27
30
|
private client;
|
|
31
|
+
private clientName;
|
|
28
32
|
private upstreamToken;
|
|
29
33
|
private profile;
|
|
30
34
|
private callbackUrl;
|
|
@@ -35,6 +39,7 @@ export declare class HostedOAuth {
|
|
|
35
39
|
authenticate(req: IncomingMessage): Promise<string>;
|
|
36
40
|
private revoke;
|
|
37
41
|
private issue;
|
|
42
|
+
private refresh;
|
|
38
43
|
private limit;
|
|
39
44
|
handle(req: IncomingMessage, res: ServerResponse, url: URL): Promise<boolean>;
|
|
40
45
|
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;AA6JD,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,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;IA8NnF,OAAO,CAAC,eAAe;CAC1B"}
|
package/dist/hosted-oauth.js
CHANGED
|
@@ -22,12 +22,13 @@ 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,7 @@ 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-origin{display:block;overflow:hidden;text-overflow:ellipsis;color:#
|
|
55
|
+
.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-name{display:block;color:#f0f0f5;font-size:14px}.app-origin{display:block;overflow:hidden;text-overflow:ellipsis;color:#d7dbe0;font:500 12px/1.45 Consolas,monospace;white-space:nowrap}.app-label{display:block;color:var(--muted);font-size:11px}
|
|
55
56
|
.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
57
|
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
58
|
.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}
|
|
@@ -131,19 +132,63 @@ function challenge(verifier) {
|
|
|
131
132
|
throw new HostedOAuthError("Invalid PKCE verifier", 400, "invalid_grant");
|
|
132
133
|
return createHash("sha256").update(verifier).digest("base64url");
|
|
133
134
|
}
|
|
135
|
+
async function safeResponseExcerpt(response) {
|
|
136
|
+
let value;
|
|
137
|
+
try {
|
|
138
|
+
value = await response.clone().text();
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return "[unreadable response body]";
|
|
142
|
+
}
|
|
143
|
+
return value.slice(0, 2048)
|
|
144
|
+
.replace(/Bearer\s+[^\s"'<]+/gi, "Bearer [redacted]")
|
|
145
|
+
.replace(/(access_token|refresh_token|id_token|client_secret|authorization|assertion|password|code|state)(["']?\s*[:=]\s*["']?)[^"',\s<}&]+/gi, "$1$2[redacted]");
|
|
146
|
+
}
|
|
147
|
+
async function providerMetadata(url) {
|
|
148
|
+
const attempts = 4;
|
|
149
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
150
|
+
let response;
|
|
151
|
+
try {
|
|
152
|
+
response = await fetch(url, { redirect: "error", signal: AbortSignal.timeout(10000) });
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
if (attempt === attempts)
|
|
156
|
+
throw new Error(`OAuth provider metadata request failed for ${url}`, { cause: error });
|
|
157
|
+
console.error(`[modlens] oauth ${JSON.stringify({ event: "provider_metadata_retry", endpoint: url.toString(),
|
|
158
|
+
attempt, reason: error instanceof Error ? error.name : "request_error" })}`);
|
|
159
|
+
await new Promise(resolve => setTimeout(resolve, 500 * 2 ** (attempt - 1)));
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
console.error(`[modlens] oauth ${JSON.stringify({ event: "provider_metadata_response", endpoint: url.toString(),
|
|
163
|
+
attempt, status: response.status, contentType: response.headers.get("content-type") ?? "",
|
|
164
|
+
...(response.ok ? {} : { response: await safeResponseExcerpt(response) }) })}`);
|
|
165
|
+
if (response.ok || response.status === 404 || (response.status !== 429 && response.status < 500))
|
|
166
|
+
return response;
|
|
167
|
+
if (attempt === attempts)
|
|
168
|
+
return response;
|
|
169
|
+
console.error(`[modlens] oauth ${JSON.stringify({ event: "provider_metadata_retry", endpoint: url.toString(),
|
|
170
|
+
attempt, status: response.status })}`);
|
|
171
|
+
await response.body?.cancel();
|
|
172
|
+
await new Promise(resolve => setTimeout(resolve, 500 * 2 ** (attempt - 1)));
|
|
173
|
+
}
|
|
174
|
+
throw new Error("OAuth provider metadata retry loop ended unexpectedly");
|
|
175
|
+
}
|
|
134
176
|
export class HostedOAuth {
|
|
135
177
|
config;
|
|
136
178
|
database;
|
|
137
179
|
ready;
|
|
180
|
+
instance = randomBytes(6).toString("hex");
|
|
138
181
|
requestCounts = new Map();
|
|
182
|
+
refreshes = new Map();
|
|
139
183
|
constructor(config, database) {
|
|
140
184
|
this.config = config;
|
|
141
185
|
this.database = database;
|
|
142
186
|
}
|
|
143
187
|
audit(event, fields = {}) {
|
|
144
|
-
console.error(`[modlens] oauth ${JSON.stringify({ event, ...fields })}`);
|
|
188
|
+
console.error(`[modlens] oauth ${JSON.stringify({ event, instance: this.instance, ...fields })}`);
|
|
145
189
|
}
|
|
146
190
|
clientRef(clientId) { return sha(clientId).slice(0, 12); }
|
|
191
|
+
grantRef(grantId) { return sha(grantId).slice(0, 12); }
|
|
147
192
|
logError(method, route, error) {
|
|
148
193
|
const incident = randomBytes(6).toString("hex");
|
|
149
194
|
this.audit("request_failed", { incident, flow: error.flowId, method: method ?? "UNKNOWN", route,
|
|
@@ -169,12 +214,14 @@ export class HostedOAuth {
|
|
|
169
214
|
if (!!requiredField !== (env.MODLENS_OAUTH_REQUIRED_VALUE !== undefined))
|
|
170
215
|
throw new Error("MODLENS_OAUTH_REQUIRED_FIELD and MODLENS_OAUTH_REQUIRED_VALUE must be configured together");
|
|
171
216
|
const path = new URL(issuer).pathname.replace(/\/$/, "");
|
|
172
|
-
|
|
173
|
-
let response = await
|
|
174
|
-
if (response.status === 404)
|
|
175
|
-
|
|
217
|
+
let metadataUrl = new URL(`/.well-known/oauth-authorization-server${path === "/" ? "" : path}`, issuer);
|
|
218
|
+
let response = await providerMetadata(metadataUrl);
|
|
219
|
+
if (response.status === 404) {
|
|
220
|
+
metadataUrl = new URL(".well-known/openid-configuration", issuer + "/");
|
|
221
|
+
response = await providerMetadata(metadataUrl);
|
|
222
|
+
}
|
|
176
223
|
if (!response.ok)
|
|
177
|
-
throw new Error(
|
|
224
|
+
throw new Error(`OAuth provider metadata unavailable: HTTP ${response.status} from ${metadataUrl}`);
|
|
178
225
|
const metadata = await response.json();
|
|
179
226
|
if (metadata.issuer !== issuer)
|
|
180
227
|
throw new Error("OAuth provider issuer mismatch");
|
|
@@ -193,6 +240,8 @@ export class HostedOAuth {
|
|
|
193
240
|
this.ready = (async () => {
|
|
194
241
|
await db.$executeRawUnsafe(`CREATE TABLE IF NOT EXISTS hosted_oauth_clients (
|
|
195
242
|
id TEXT PRIMARY KEY, redirect_uris TEXT NOT NULL, created BIGINT NOT NULL)`);
|
|
243
|
+
await db.$executeRawUnsafe(`CREATE TABLE IF NOT EXISTS hosted_oauth_client_metadata (
|
|
244
|
+
id TEXT PRIMARY KEY, client_name TEXT NOT NULL)`);
|
|
196
245
|
await db.$executeRawUnsafe(`CREATE TABLE IF NOT EXISTS hosted_oauth_objects (
|
|
197
246
|
id TEXT PRIMARY KEY, kind TEXT NOT NULL, payload TEXT NOT NULL, expires BIGINT NOT NULL)`);
|
|
198
247
|
await db.$executeRawUnsafe(`CREATE TABLE IF NOT EXISTS hosted_oauth_grants (
|
|
@@ -252,6 +301,11 @@ export class HostedOAuth {
|
|
|
252
301
|
throw new HostedOAuthError("Unknown OAuth client", 400, "invalid_client");
|
|
253
302
|
return JSON.parse(rows[0].redirect_uris);
|
|
254
303
|
}
|
|
304
|
+
async clientName(clientId) {
|
|
305
|
+
const db = await this.db();
|
|
306
|
+
const rows = await db.$queryRawUnsafe(`SELECT client_name FROM hosted_oauth_client_metadata WHERE id=$1`, clientId);
|
|
307
|
+
return rows[0]?.client_name;
|
|
308
|
+
}
|
|
255
309
|
async upstreamToken(values) {
|
|
256
310
|
const headers = { "Content-Type": "application/x-www-form-urlencoded" };
|
|
257
311
|
values.set("client_id", this.config.clientId);
|
|
@@ -265,8 +319,12 @@ export class HostedOAuth {
|
|
|
265
319
|
catch {
|
|
266
320
|
throw new HostedOAuthError("Provider token endpoint unavailable", 503, "temporarily_unavailable");
|
|
267
321
|
}
|
|
268
|
-
if (!response.ok)
|
|
322
|
+
if (!response.ok) {
|
|
323
|
+
this.audit("provider_token_response", { endpoint: this.config.tokenUrl, grantType: values.get("grant_type") ?? "unknown",
|
|
324
|
+
status: response.status, contentType: response.headers.get("content-type") ?? "",
|
|
325
|
+
response: await safeResponseExcerpt(response) });
|
|
269
326
|
throw new HostedOAuthError("Provider token exchange failed", response.status >= 500 ? 503 : 401, "invalid_grant");
|
|
327
|
+
}
|
|
270
328
|
let data;
|
|
271
329
|
try {
|
|
272
330
|
data = await response.json();
|
|
@@ -288,8 +346,11 @@ export class HostedOAuth {
|
|
|
288
346
|
catch {
|
|
289
347
|
throw new HostedOAuthError("Provider profile unavailable", 503, "temporarily_unavailable");
|
|
290
348
|
}
|
|
291
|
-
if (!response.ok)
|
|
349
|
+
if (!response.ok) {
|
|
350
|
+
this.audit("provider_profile_response", { endpoint: this.config.profileUrl, status: response.status,
|
|
351
|
+
contentType: response.headers.get("content-type") ?? "", bodyLogged: false });
|
|
292
352
|
throw new HostedOAuthError("Provider profile unavailable", response.status === 401 || response.status === 403 ? 401 : 503, response.status === 401 || response.status === 403 ? "invalid_token" : "temporarily_unavailable");
|
|
353
|
+
}
|
|
293
354
|
let data;
|
|
294
355
|
try {
|
|
295
356
|
data = await response.json();
|
|
@@ -356,21 +417,22 @@ export class HostedOAuth {
|
|
|
356
417
|
try {
|
|
357
418
|
const profile = await this.profile(this.open(grant.upstream_access));
|
|
358
419
|
if (!profile.allowed || profile.subject !== grant.subject) {
|
|
359
|
-
await this.revoke(grant.id);
|
|
420
|
+
await this.revoke(grant.id, "profile_access_denied");
|
|
360
421
|
throw new HostedOAuthError("Account access denied", 403, "access_denied");
|
|
361
422
|
}
|
|
362
423
|
}
|
|
363
424
|
catch (error) {
|
|
364
425
|
if (error instanceof HostedOAuthError && error.status === 401)
|
|
365
|
-
await this.revoke(grant.id);
|
|
426
|
+
await this.revoke(grant.id, "profile_unauthorized");
|
|
366
427
|
throw error;
|
|
367
428
|
}
|
|
368
429
|
return sha(this.config.issuer + "\0" + grant.subject);
|
|
369
430
|
}
|
|
370
|
-
async revoke(id) {
|
|
431
|
+
async revoke(id, reason) {
|
|
371
432
|
const db = await this.db();
|
|
372
433
|
await db.$executeRawUnsafe(`DELETE FROM hosted_oauth_access WHERE grant_id=$1`, id);
|
|
373
434
|
await db.$executeRawUnsafe(`DELETE FROM hosted_oauth_grants WHERE id=$1`, id);
|
|
435
|
+
this.audit("grant_revoked", { grant: this.grantRef(id), reason });
|
|
374
436
|
}
|
|
375
437
|
async issue(grant) {
|
|
376
438
|
const rawAccess = token("mla_");
|
|
@@ -379,6 +441,57 @@ export class HostedOAuth {
|
|
|
379
441
|
await db.$executeRawUnsafe(`INSERT INTO hosted_oauth_access (token_hash,grant_id,expires) VALUES ($1,$2,$3)`, sha(rawAccess), grant.id, now() + expires);
|
|
380
442
|
return { access_token: rawAccess, token_type: "Bearer", expires_in: expires, scope: "modlens" };
|
|
381
443
|
}
|
|
444
|
+
async refresh(clientId, raw) {
|
|
445
|
+
const db = await this.db();
|
|
446
|
+
const rows = await db.$queryRawUnsafe(`UPDATE hosted_oauth_grants SET refresh_hash=NULL
|
|
447
|
+
WHERE refresh_hash=$1 AND client_id=$2 AND refresh_expires>$3 RETURNING *`, sha(raw), clientId, now());
|
|
448
|
+
if (rows.length !== 1 || !rows[0].upstream_refresh) {
|
|
449
|
+
this.audit("refresh_rejected", { client: this.clientRef(clientId), refreshRef: sha(raw).slice(0, 12),
|
|
450
|
+
reason: rows.length === 1 ? "no_upstream_refresh" : "not_current_or_missing" });
|
|
451
|
+
throw new HostedOAuthError("Invalid refresh token", 400, "invalid_grant");
|
|
452
|
+
}
|
|
453
|
+
const grant = rows[0];
|
|
454
|
+
let stage = "provider_token";
|
|
455
|
+
try {
|
|
456
|
+
const oldUpstreamRefresh = grant.upstream_refresh;
|
|
457
|
+
const upstream = await this.upstreamToken(new URLSearchParams({ grant_type: "refresh_token",
|
|
458
|
+
refresh_token: this.open(oldUpstreamRefresh) }));
|
|
459
|
+
grant.upstream_access = this.seal(upstream.access_token);
|
|
460
|
+
grant.upstream_refresh = this.seal(upstream.refresh_token ?? this.open(oldUpstreamRefresh));
|
|
461
|
+
grant.upstream_expires = now() + Math.max(1, Math.min(86400, Number(upstream.expires_in) || 300));
|
|
462
|
+
stage = "grant_update";
|
|
463
|
+
await db.$executeRawUnsafe(`UPDATE hosted_oauth_grants SET upstream_access=$2,upstream_refresh=$3,
|
|
464
|
+
upstream_expires=$4 WHERE id=$1`, grant.id, grant.upstream_access, grant.upstream_refresh, grant.upstream_expires);
|
|
465
|
+
stage = "profile";
|
|
466
|
+
const profile = await this.profile(upstream.access_token);
|
|
467
|
+
if (profile.subject !== grant.subject || !profile.allowed)
|
|
468
|
+
throw new HostedOAuthError("Account access denied", 403, "access_denied");
|
|
469
|
+
const next = token("mlr_");
|
|
470
|
+
grant.refresh_hash = sha(next);
|
|
471
|
+
stage = "rotation";
|
|
472
|
+
await db.$executeRawUnsafe(`UPDATE hosted_oauth_grants SET refresh_hash=$2 WHERE id=$1`, grant.id, grant.refresh_hash);
|
|
473
|
+
stage = "access_issue";
|
|
474
|
+
const access = await this.issue(grant);
|
|
475
|
+
const response = { ...access, refresh_token: next };
|
|
476
|
+
this.audit("refresh_issued", { client: this.clientRef(clientId), grant: this.grantRef(grant.id),
|
|
477
|
+
previousRefreshRef: sha(raw).slice(0, 12), refreshRef: sha(next).slice(0, 12),
|
|
478
|
+
accessExpiresIn: Number(access.expires_in) });
|
|
479
|
+
return response;
|
|
480
|
+
}
|
|
481
|
+
catch (error) {
|
|
482
|
+
const retryable = error instanceof HostedOAuthError && error.status === 503;
|
|
483
|
+
this.audit("refresh_failed", { client: this.clientRef(clientId), grant: this.grantRef(grant.id),
|
|
484
|
+
refreshRef: sha(raw).slice(0, 12), stage, retryable,
|
|
485
|
+
status: error instanceof HostedOAuthError ? error.status : undefined,
|
|
486
|
+
code: error instanceof HostedOAuthError ? error.code : undefined });
|
|
487
|
+
if (retryable) {
|
|
488
|
+
await db.$executeRawUnsafe(`UPDATE hosted_oauth_grants SET refresh_hash=$2 WHERE id=$1 AND refresh_hash IS NULL`, grant.id, sha(raw));
|
|
489
|
+
}
|
|
490
|
+
else
|
|
491
|
+
await this.revoke(grant.id, "refresh_failed");
|
|
492
|
+
throw error;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
382
495
|
limit(req, route, maximum) {
|
|
383
496
|
const minute = Math.floor(now() / 60);
|
|
384
497
|
const key = `${route}:${req.socket.remoteAddress ?? "unknown"}`;
|
|
@@ -427,12 +540,19 @@ export class HostedOAuth {
|
|
|
427
540
|
!redirects.every(value => typeof value === "string" && callbackSafe(value)) ||
|
|
428
541
|
(registration.token_endpoint_auth_method && registration.token_endpoint_auth_method !== "none"))
|
|
429
542
|
throw new HostedOAuthError("Invalid client registration");
|
|
543
|
+
const clientName = registration.client_name;
|
|
544
|
+
if (clientName !== undefined && (typeof clientName !== "string" || !clientName.trim() ||
|
|
545
|
+
clientName.length > 120 || /[\x00-\x1f\x7f]/.test(clientName)))
|
|
546
|
+
throw new HostedOAuthError("Invalid client name");
|
|
430
547
|
const id = token("mlc_");
|
|
431
548
|
const db = await this.db();
|
|
432
549
|
await db.$executeRawUnsafe(`INSERT INTO hosted_oauth_clients (id,redirect_uris,created) VALUES ($1,$2,$3)`, id, JSON.stringify(redirects), now());
|
|
550
|
+
if (clientName !== undefined)
|
|
551
|
+
await db.$executeRawUnsafe(`INSERT INTO hosted_oauth_client_metadata (id,client_name) VALUES ($1,$2)`, id, clientName);
|
|
433
552
|
this.audit("client_registered", { client: this.clientRef(id), redirects: redirects.length });
|
|
434
553
|
json(res, 201, { client_id: id, redirect_uris: redirects, grant_types: ["authorization_code", "refresh_token"],
|
|
435
|
-
response_types: ["code"], token_endpoint_auth_method: "none"
|
|
554
|
+
response_types: ["code"], token_endpoint_auth_method: "none",
|
|
555
|
+
...(clientName === undefined ? {} : { client_name: clientName }) });
|
|
436
556
|
return true;
|
|
437
557
|
}
|
|
438
558
|
if (route === "/oauth/authorize" && req.method === "GET") {
|
|
@@ -493,13 +613,16 @@ export class HostedOAuth {
|
|
|
493
613
|
const approval = await this.putObject("approval", { ...state, upstream, subject: profile.subject }, 600);
|
|
494
614
|
this.audit("consent_presented", { flow: state.flowId, client: this.clientRef(state.clientId),
|
|
495
615
|
redirectOrigin: callback.origin });
|
|
616
|
+
const clientName = await this.clientName(state.clientId);
|
|
496
617
|
const destination = escapeHtml(callback.origin);
|
|
497
618
|
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-
|
|
619
|
+
+ `<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 class="app-origin">Callback: ${destination}</span></span></div>`
|
|
499
620
|
+ `<ul class="permissions"><li>Run hosted ModLens tools on your behalf</li><li>Use your hosted account allowance</li></ul>`
|
|
500
621
|
+ `<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
622
|
+ `<p class="note">Only continue if you started this request in the app.</p>`;
|
|
502
|
-
|
|
623
|
+
// Browsers may apply form-action to the redirect after approval as well as the POST.
|
|
624
|
+
res.writeHead(200, { ...htmlHeaders,
|
|
625
|
+
"Content-Security-Policy": htmlCsp(`'self' ${callback.origin}`) });
|
|
503
626
|
res.end(page("Connect", content));
|
|
504
627
|
return true;
|
|
505
628
|
}
|
|
@@ -562,8 +685,11 @@ export class HostedOAuth {
|
|
|
562
685
|
const db = await this.db();
|
|
563
686
|
await db.$executeRawUnsafe(`INSERT INTO hosted_oauth_grants (id,client_id,subject,resource,upstream_access,upstream_refresh,
|
|
564
687
|
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
|
|
566
|
-
|
|
688
|
+
const access = await this.issue(grant);
|
|
689
|
+
const response = { ...access, ...(refresh ? { refresh_token: refresh } : {}) };
|
|
690
|
+
this.audit("token_issued", { flow: code.flowId, client: this.clientRef(clientId), refresh: !!refresh,
|
|
691
|
+
grant: this.grantRef(grant.id), accessExpiresIn: Number(access.expires_in),
|
|
692
|
+
...(refresh ? { refreshRef: sha(refresh).slice(0, 12) } : {}) });
|
|
567
693
|
json(res, 200, response);
|
|
568
694
|
return true;
|
|
569
695
|
}
|
|
@@ -573,37 +699,17 @@ export class HostedOAuth {
|
|
|
573
699
|
if (params.has("scope") && params.get("scope") !== "modlens")
|
|
574
700
|
throw new HostedOAuthError("Unsupported scope", 400, "invalid_scope");
|
|
575
701
|
const raw = required(params, "refresh_token");
|
|
576
|
-
const
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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;
|
|
702
|
+
const key = `${clientId}:${sha(raw)}`;
|
|
703
|
+
let pending = this.refreshes.get(key);
|
|
704
|
+
if (!pending) {
|
|
705
|
+
this.audit("refresh_started", { client: this.clientRef(clientId), refreshRef: sha(raw).slice(0, 12) });
|
|
706
|
+
pending = this.refresh(clientId, raw);
|
|
707
|
+
this.refreshes.set(key, pending);
|
|
708
|
+
void pending.finally(() => this.refreshes.delete(key)).catch(() => { });
|
|
606
709
|
}
|
|
710
|
+
else
|
|
711
|
+
this.audit("refresh_joined", { client: this.clientRef(clientId), refreshRef: sha(raw).slice(0, 12) });
|
|
712
|
+
json(res, 200, await pending);
|
|
607
713
|
return true;
|
|
608
714
|
}
|
|
609
715
|
throw new HostedOAuthError("Unsupported grant type", 400, "unsupported_grant_type");
|
|
@@ -616,12 +722,12 @@ export class HostedOAuth {
|
|
|
616
722
|
const db = await this.db();
|
|
617
723
|
const rows = await db.$queryRawUnsafe(`SELECT id FROM hosted_oauth_grants WHERE client_id=$1 AND refresh_hash=$2`, clientId, sha(raw));
|
|
618
724
|
if (rows.length)
|
|
619
|
-
await this.revoke(rows[0].id);
|
|
725
|
+
await this.revoke(rows[0].id, "client_request");
|
|
620
726
|
else {
|
|
621
727
|
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
728
|
WHERE g.client_id=$1 AND a.token_hash=$2`, clientId, sha(raw));
|
|
623
729
|
if (access.length)
|
|
624
|
-
await this.revoke(access[0].id);
|
|
730
|
+
await this.revoke(access[0].id, "client_request");
|
|
625
731
|
}
|
|
626
732
|
json(res, 200, {});
|
|
627
733
|
return true;
|