dsh-oauth-login 0.2.4
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/INSTALL.md +59 -0
- package/INSTALL.zh.md +45 -0
- package/LICENSE +201 -0
- package/README.en.md +104 -0
- package/README.md +104 -0
- package/README.zh.md +3 -0
- package/SECURITY.md +33 -0
- package/cordis.patch.yml +5 -0
- package/docs/network-proxy.md +45 -0
- package/docs/openrouter-sync.md +84 -0
- package/docs/screenshots/01-providers-signed-out.png +0 -0
- package/docs/screenshots/03-copilot-signing-in.png +0 -0
- package/docs/screenshots/04-mixed-live-states.png +0 -0
- package/docs/screenshots/05-copilot-sign-in.gif +0 -0
- package/docs/screenshots/06-cli-status-store.png +0 -0
- package/lib/bin.d.ts +5 -0
- package/lib/bin.js +2132 -0
- package/lib/client.js +1915 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +482 -0
- package/lib/index.js +3105 -0
- package/lib/invariant.d.ts +7 -0
- package/lib/invariant.js +8 -0
- package/package.json +180 -0
- package/src/adapter.ts +207 -0
- package/src/auth-routes.ts +654 -0
- package/src/auth.ts +76 -0
- package/src/bin.ts +185 -0
- package/src/catalog.ts +129 -0
- package/src/client/OpenRouterSyncStatus.tsx +66 -0
- package/src/client/PiLoginSettings.tsx +470 -0
- package/src/client/ProxySettings.tsx +123 -0
- package/src/client/SearchableModelSelect.tsx +595 -0
- package/src/client/draft-input.ts +12 -0
- package/src/client/index.tsx +71 -0
- package/src/client/locales.ts +176 -0
- package/src/client/login-input-copy.ts +34 -0
- package/src/client/login-window.ts +24 -0
- package/src/client/model-filter.ts +45 -0
- package/src/client/openrouter-store.ts +122 -0
- package/src/client/proxy-draft.ts +57 -0
- package/src/extra-models.ts +201 -0
- package/src/hosted-capture.ts +41 -0
- package/src/hosted-images.ts +154 -0
- package/src/http.ts +241 -0
- package/src/ids.ts +17 -0
- package/src/index.ts +183 -0
- package/src/invariant.ts +12 -0
- package/src/model-error-hint.ts +98 -0
- package/src/native-tools.ts +442 -0
- package/src/oauth-refresh.ts +49 -0
- package/src/openrouter-catalog.ts +245 -0
- package/src/openrouter-free.ts +76 -0
- package/src/openrouter-models.ts +155 -0
- package/src/openrouter-types.ts +40 -0
- package/src/plugin-config.ts +30 -0
- package/src/provider.ts +137 -0
- package/src/proxy-config.ts +61 -0
- package/src/proxy-store.ts +45 -0
- package/src/proxy-transport.ts +220 -0
- package/src/redact.ts +22 -0
- package/src/responses-tap.ts +97 -0
- package/src/session.ts +104 -0
- package/src/store.ts +234 -0
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
/** Same-origin Web settings routes for Pi-native multi-provider OAuth. */
|
|
2
|
+
|
|
3
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
4
|
+
import type { AuthEvent, AuthPrompt } from '@earendil-works/pi-ai'
|
|
5
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
6
|
+
import type {} from '@deepseek-ai/dsh-client-connection'
|
|
7
|
+
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
8
|
+
import { loginPiProviderSession, piLoginStatus } from './auth.ts'
|
|
9
|
+
import { PI_LOGIN_PROVIDERS, requirePiLoginProvider } from './catalog.ts'
|
|
10
|
+
import type { PiLoginProvider } from './catalog.ts'
|
|
11
|
+
import { isSafeAuthUrl, safeMessage } from './redact.ts'
|
|
12
|
+
import type { PiLoginSession } from './session.ts'
|
|
13
|
+
import { OPENROUTER_CATALOG_PATH, OPENROUTER_REFRESH_PATH } from './openrouter-types.ts'
|
|
14
|
+
import { parseProxySettings, PROXY_SETTINGS_PATH } from './proxy-config.ts'
|
|
15
|
+
import { ProxySettingsConflict } from './proxy-store.ts'
|
|
16
|
+
|
|
17
|
+
export const PI_LOGIN_AUTH_STATUS_PATH = '/plugins/dsh-oauth-login/auth/status'
|
|
18
|
+
export const PI_LOGIN_AUTH_LOGIN_PATH = '/plugins/dsh-oauth-login/auth/login'
|
|
19
|
+
export const PI_LOGIN_AUTH_CANCEL_PATH = '/plugins/dsh-oauth-login/auth/cancel'
|
|
20
|
+
export const PI_LOGIN_AUTH_COMPLETE_PATH = '/plugins/dsh-oauth-login/auth/complete'
|
|
21
|
+
export const PI_LOGIN_AUTH_LOGOUT_PATH = '/plugins/dsh-oauth-login/auth/logout'
|
|
22
|
+
|
|
23
|
+
export interface LoginInputChallenge {
|
|
24
|
+
type: 'secret' | 'text' | 'manual_code'
|
|
25
|
+
message: string
|
|
26
|
+
placeholder?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type InputAuthPrompt = Extract<AuthPrompt, { type: 'secret' | 'text' | 'manual_code' }>
|
|
30
|
+
|
|
31
|
+
/** Preserve the Pi prompt discriminator on the browser wire contract. */
|
|
32
|
+
export function loginInputChallenge(prompt: InputAuthPrompt): LoginInputChallenge {
|
|
33
|
+
return {
|
|
34
|
+
type: prompt.type,
|
|
35
|
+
message: prompt.message,
|
|
36
|
+
...'placeholder' in prompt && prompt.placeholder !== undefined
|
|
37
|
+
? { placeholder: prompt.placeholder }
|
|
38
|
+
: {},
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type PiLoginAccountState =
|
|
43
|
+
| { status: 'signed-out' }
|
|
44
|
+
| {
|
|
45
|
+
status: 'signing-in'
|
|
46
|
+
kind?: 'browser' | 'input'
|
|
47
|
+
url?: string
|
|
48
|
+
userCode?: string
|
|
49
|
+
input?: LoginInputChallenge
|
|
50
|
+
}
|
|
51
|
+
| { status: 'signed-in'; models: string[]; expiresAt?: string }
|
|
52
|
+
| { status: 'error'; message: string }
|
|
53
|
+
|
|
54
|
+
export interface PiLoginProviderStatus {
|
|
55
|
+
id: string
|
|
56
|
+
route: string
|
|
57
|
+
displayName: string
|
|
58
|
+
shortName: string
|
|
59
|
+
authType: PiLoginProvider['authType']
|
|
60
|
+
account: PiLoginAccountState
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface LoginChallenge {
|
|
64
|
+
provider: string
|
|
65
|
+
kind: 'browser' | 'input'
|
|
66
|
+
url?: string
|
|
67
|
+
userCode?: string
|
|
68
|
+
input?: LoginInputChallenge
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Keep the browser authorization URL available while Pi also waits for a manual callback. */
|
|
72
|
+
export function mergeLoginChallenge(
|
|
73
|
+
previous: LoginChallenge | undefined,
|
|
74
|
+
next: LoginChallenge,
|
|
75
|
+
): LoginChallenge {
|
|
76
|
+
return {
|
|
77
|
+
...next,
|
|
78
|
+
...next.url === undefined && previous?.url !== undefined ? { url: previous.url } : {},
|
|
79
|
+
...next.userCode === undefined && previous?.userCode !== undefined ? { userCode: previous.userCode } : {},
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function waitForPromptAbort(prompt: AuthPrompt): Promise<string> {
|
|
84
|
+
const signal = prompt.signal
|
|
85
|
+
if (signal === undefined) return new Promise<string>(() => {})
|
|
86
|
+
if (signal.aborted) return Promise.reject(signal.reason)
|
|
87
|
+
return new Promise<string>((_resolve, reject) => {
|
|
88
|
+
signal.addEventListener('abort', () => { reject(signal.reason) }, { once: true })
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function answerSelectPrompt(prompt: AuthPrompt): string | undefined {
|
|
93
|
+
if (prompt.type === 'select') {
|
|
94
|
+
const oauth = prompt.options.find(option => option.id === 'oauth' || option.id.includes('oauth'))
|
|
95
|
+
const browser = prompt.options.find(option => option.id.includes('browser'))
|
|
96
|
+
return oauth?.id ?? browser?.id ?? prompt.options[0]?.id ?? 'oauth'
|
|
97
|
+
}
|
|
98
|
+
return undefined
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface PendingInput {
|
|
102
|
+
readonly operationId: number
|
|
103
|
+
resolve(value: string): void
|
|
104
|
+
reject(error: unknown): void
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface LoginOperation {
|
|
108
|
+
readonly id: number
|
|
109
|
+
readonly cancellation: AbortController
|
|
110
|
+
readonly done: Promise<void>
|
|
111
|
+
cancelled?: Error
|
|
112
|
+
failure?: Error
|
|
113
|
+
result?: PiLoginAccountState
|
|
114
|
+
finish(): void
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
class ProviderAuth {
|
|
118
|
+
state: PiLoginAccountState = { status: 'signed-out' }
|
|
119
|
+
private operation: LoginOperation | undefined
|
|
120
|
+
private nextOperationId = 0
|
|
121
|
+
private challenge: LoginChallenge | undefined
|
|
122
|
+
private challengeWaiters: Array<{ resolve(value: LoginChallenge): void; reject(error: unknown): void }> = []
|
|
123
|
+
private pendingInput: PendingInput | undefined
|
|
124
|
+
|
|
125
|
+
constructor(
|
|
126
|
+
private readonly spec: PiLoginProvider,
|
|
127
|
+
private readonly session: PiLoginSession,
|
|
128
|
+
) {}
|
|
129
|
+
|
|
130
|
+
async snapshot(): Promise<PiLoginAccountState> {
|
|
131
|
+
if (this.operation !== undefined) return this.state
|
|
132
|
+
if (this.state.status === 'error') return this.state
|
|
133
|
+
return this.readStored()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async signIn(): Promise<LoginChallenge> {
|
|
137
|
+
if (this.operation === undefined) this.start()
|
|
138
|
+
if (this.challenge !== undefined) return this.challenge
|
|
139
|
+
return new Promise<LoginChallenge>((resolve, reject) => {
|
|
140
|
+
this.challengeWaiters.push({ resolve, reject })
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async signOut(): Promise<void> {
|
|
145
|
+
const revision = this.nextOperationId
|
|
146
|
+
const error = new Error('Pi login cancelled')
|
|
147
|
+
this.stop(this.operation, error)
|
|
148
|
+
await this.session.logout(this.spec.id)
|
|
149
|
+
if (this.operation === undefined && this.nextOperationId === revision) {
|
|
150
|
+
this.state = { status: 'signed-out' }
|
|
151
|
+
this.challenge = undefined
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async cancel(): Promise<void> {
|
|
156
|
+
const revision = this.nextOperationId
|
|
157
|
+
const error = new Error('Pi login cancelled')
|
|
158
|
+
this.stop(this.operation, error)
|
|
159
|
+
const stored = await this.readStored()
|
|
160
|
+
if (this.operation === undefined && this.nextOperationId === revision) {
|
|
161
|
+
this.state = stored
|
|
162
|
+
this.challenge = undefined
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async submitInput(value: string): Promise<PiLoginAccountState> {
|
|
167
|
+
const pending = this.pendingInput
|
|
168
|
+
if (pending === undefined) {
|
|
169
|
+
throw new Error(`${this.spec.displayName} is not waiting for a credential`)
|
|
170
|
+
}
|
|
171
|
+
const normalized = value.trim()
|
|
172
|
+
if (normalized.length === 0) throw new Error('credential must not be empty')
|
|
173
|
+
if (normalized.length > 4096) throw new Error('credential is too long')
|
|
174
|
+
const operation = this.operation
|
|
175
|
+
if (operation === undefined || operation.id !== pending.operationId) {
|
|
176
|
+
throw new Error(`${this.spec.displayName} login operation is no longer active`)
|
|
177
|
+
}
|
|
178
|
+
pending.resolve(normalized)
|
|
179
|
+
await operation.done
|
|
180
|
+
if (operation.cancelled !== undefined) throw operation.cancelled
|
|
181
|
+
if (operation.failure !== undefined) throw operation.failure
|
|
182
|
+
return operation.result ?? this.state
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Wait until an in-flight sign-in settles (success or error). No-op if idle. */
|
|
186
|
+
async waitUntilSettled(): Promise<void> {
|
|
187
|
+
await this.operation?.done
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async dispose(): Promise<void> {
|
|
191
|
+
const error = new Error('Pi login plugin disposed')
|
|
192
|
+
this.stop(this.operation, error)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private start(): void {
|
|
196
|
+
const cancellation = new AbortController()
|
|
197
|
+
const id = ++this.nextOperationId
|
|
198
|
+
let finish!: () => void
|
|
199
|
+
const done = new Promise<void>(resolve => { finish = resolve })
|
|
200
|
+
const operation: LoginOperation = { id, cancellation, done, finish }
|
|
201
|
+
this.operation = operation
|
|
202
|
+
this.challenge = undefined
|
|
203
|
+
this.pendingInput = undefined
|
|
204
|
+
this.state = { status: 'signing-in' }
|
|
205
|
+
void loginPiProviderSession(this.spec.id, {
|
|
206
|
+
signal: cancellation.signal,
|
|
207
|
+
prompt: prompt => this.onPrompt(id, prompt),
|
|
208
|
+
notify: event => { this.onEvent(id, event) },
|
|
209
|
+
}, this.session).then(async () => {
|
|
210
|
+
if (!this.isCurrent(id)) return
|
|
211
|
+
const stored = await this.readStored()
|
|
212
|
+
if (!this.isCurrent(id)) return
|
|
213
|
+
operation.result = stored
|
|
214
|
+
this.state = stored
|
|
215
|
+
}).catch((error: unknown) => {
|
|
216
|
+
if (!this.isCurrent(id)) return
|
|
217
|
+
this.rejectChallenge(error)
|
|
218
|
+
this.rejectInput(error)
|
|
219
|
+
operation.failure = new Error(safeMessage(error))
|
|
220
|
+
this.state = { status: 'error', message: operation.failure.message }
|
|
221
|
+
}).finally(() => {
|
|
222
|
+
if (this.isCurrent(id)) this.operation = undefined
|
|
223
|
+
operation.finish()
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private onPrompt(operationId: number, prompt: AuthPrompt): Promise<string> {
|
|
228
|
+
if (!this.isCurrent(operationId)) return Promise.reject(new Error('Pi login cancelled'))
|
|
229
|
+
const selected = answerSelectPrompt(prompt)
|
|
230
|
+
if (selected !== undefined) return Promise.resolve(selected)
|
|
231
|
+
// Preserve the existing optional-text behavior in OAuth providers. Secret
|
|
232
|
+
// and manual-code prompts require an explicit local user action.
|
|
233
|
+
if (prompt.type === 'text' && this.spec.authType === 'oauth') return Promise.resolve('')
|
|
234
|
+
if (prompt.type === 'secret' || prompt.type === 'manual_code' || prompt.type === 'text') {
|
|
235
|
+
return this.requestInput(operationId, prompt)
|
|
236
|
+
}
|
|
237
|
+
return waitForPromptAbort(prompt)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private requestInput(operationId: number, prompt: InputAuthPrompt): Promise<string> {
|
|
241
|
+
if (this.pendingInput !== undefined) {
|
|
242
|
+
return Promise.reject(new Error(`${this.spec.displayName} already has a pending credential prompt`))
|
|
243
|
+
}
|
|
244
|
+
const input = loginInputChallenge(prompt)
|
|
245
|
+
const operation = this.operation
|
|
246
|
+
if (operation === undefined || operation.id !== operationId) return Promise.reject(new Error('Pi login cancelled'))
|
|
247
|
+
const signals = [operation.cancellation.signal, prompt.signal]
|
|
248
|
+
.filter((signal): signal is AbortSignal => signal !== undefined)
|
|
249
|
+
const wait = new Promise<string>((resolve, reject) => {
|
|
250
|
+
let settled = false
|
|
251
|
+
const onAbort = (): void => {
|
|
252
|
+
const signal = signals.find(candidate => candidate.aborted)
|
|
253
|
+
settleReject(signal?.reason ?? new Error('credential prompt cancelled'))
|
|
254
|
+
}
|
|
255
|
+
const cleanup = (): void => {
|
|
256
|
+
for (const signal of signals) signal.removeEventListener('abort', onAbort)
|
|
257
|
+
}
|
|
258
|
+
const settleResolve = (value: string): void => {
|
|
259
|
+
if (settled) return
|
|
260
|
+
settled = true
|
|
261
|
+
cleanup()
|
|
262
|
+
this.pendingInput = undefined
|
|
263
|
+
resolve(value)
|
|
264
|
+
}
|
|
265
|
+
const settleReject = (error: unknown): void => {
|
|
266
|
+
if (settled) return
|
|
267
|
+
settled = true
|
|
268
|
+
cleanup()
|
|
269
|
+
this.pendingInput = undefined
|
|
270
|
+
reject(error)
|
|
271
|
+
}
|
|
272
|
+
this.pendingInput = { operationId, resolve: settleResolve, reject: settleReject }
|
|
273
|
+
for (const signal of signals) signal.addEventListener('abort', onAbort, { once: true })
|
|
274
|
+
if (signals.some(signal => signal.aborted)) onAbort()
|
|
275
|
+
})
|
|
276
|
+
this.acceptChallenge({
|
|
277
|
+
provider: this.spec.id,
|
|
278
|
+
kind: 'input',
|
|
279
|
+
...this.spec.loginUrl === undefined ? {} : { url: this.spec.loginUrl },
|
|
280
|
+
input,
|
|
281
|
+
})
|
|
282
|
+
return wait
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private onEvent(operationId: number, event: AuthEvent): void {
|
|
286
|
+
if (!this.isCurrent(operationId)) return
|
|
287
|
+
if (event.type === 'device_code') {
|
|
288
|
+
this.acceptChallenge({
|
|
289
|
+
provider: this.spec.id,
|
|
290
|
+
kind: 'browser',
|
|
291
|
+
url: event.verificationUri,
|
|
292
|
+
...event.userCode.length > 0 ? { userCode: event.userCode } : {},
|
|
293
|
+
})
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
if (event.type === 'auth_url') {
|
|
297
|
+
this.acceptChallenge({ provider: this.spec.id, kind: 'browser', url: event.url })
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private acceptChallenge(challenge: LoginChallenge): void {
|
|
302
|
+
if (challenge.url !== undefined && !isSafeAuthUrl(challenge.url, this.spec)) {
|
|
303
|
+
const error = new Error(`${this.spec.id} returned an authorization URL outside its official hosts`)
|
|
304
|
+
this.stop(this.operation, error)
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
const merged = mergeLoginChallenge(this.challenge, challenge)
|
|
308
|
+
this.challenge = merged
|
|
309
|
+
this.state = {
|
|
310
|
+
status: 'signing-in',
|
|
311
|
+
kind: merged.kind,
|
|
312
|
+
...merged.url === undefined ? {} : { url: merged.url },
|
|
313
|
+
...merged.userCode === undefined ? {} : { userCode: merged.userCode },
|
|
314
|
+
...merged.input === undefined ? {} : { input: merged.input },
|
|
315
|
+
}
|
|
316
|
+
for (const waiter of this.challengeWaiters.splice(0)) waiter.resolve(merged)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private async readStored(): Promise<PiLoginAccountState> {
|
|
320
|
+
const [stored] = await piLoginStatus(this.session.store, this.spec.id)
|
|
321
|
+
if (stored === undefined || !stored.authenticated) return { status: 'signed-out' }
|
|
322
|
+
return {
|
|
323
|
+
status: 'signed-in',
|
|
324
|
+
models: this.session.visibleModels(this.spec.id).map(model => model.id),
|
|
325
|
+
...stored.expiresAt === undefined || Number.isNaN(stored.expiresAt.valueOf())
|
|
326
|
+
? {}
|
|
327
|
+
: { expiresAt: stored.expiresAt.toISOString() },
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private rejectChallenge(error: unknown): void {
|
|
332
|
+
for (const waiter of this.challengeWaiters.splice(0)) waiter.reject(error)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private rejectInput(error: unknown): void {
|
|
336
|
+
this.pendingInput?.reject(error)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private isCurrent(operationId: number): boolean {
|
|
340
|
+
return this.operation?.id === operationId
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private stop(operation: LoginOperation | undefined, error: Error): void {
|
|
344
|
+
if (operation === undefined || this.operation?.id !== operation.id) return
|
|
345
|
+
this.operation = undefined
|
|
346
|
+
operation.cancelled = error
|
|
347
|
+
this.rejectChallenge(error)
|
|
348
|
+
this.rejectInput(error)
|
|
349
|
+
operation.cancellation.abort(error)
|
|
350
|
+
operation.finish()
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export class PiLoginWebAuth {
|
|
355
|
+
private readonly session: PiLoginSession
|
|
356
|
+
private readonly byId = new Map<string, ProviderAuth>()
|
|
357
|
+
|
|
358
|
+
constructor(session: PiLoginSession) {
|
|
359
|
+
this.session = session
|
|
360
|
+
for (const spec of PI_LOGIN_PROVIDERS) {
|
|
361
|
+
this.byId.set(spec.id, new ProviderAuth(spec, session))
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
private slot(id: string): ProviderAuth {
|
|
366
|
+
const slot = this.byId.get(id)
|
|
367
|
+
if (slot === undefined) throw new Error(`dsh-oauth-login: unknown provider "${id}"`)
|
|
368
|
+
return slot
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async status(): Promise<PiLoginProviderStatus[]> {
|
|
372
|
+
await this.session.refreshStoredGrants()
|
|
373
|
+
await this.session.openRouter.syncAuthentication()
|
|
374
|
+
const out: PiLoginProviderStatus[] = []
|
|
375
|
+
for (const spec of PI_LOGIN_PROVIDERS) {
|
|
376
|
+
out.push({
|
|
377
|
+
id: spec.id,
|
|
378
|
+
route: spec.route,
|
|
379
|
+
displayName: spec.displayName,
|
|
380
|
+
shortName: spec.shortName,
|
|
381
|
+
authType: spec.authType,
|
|
382
|
+
account: await this.slot(spec.id).snapshot(),
|
|
383
|
+
})
|
|
384
|
+
}
|
|
385
|
+
return out
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async signIn(id: string): Promise<LoginChallenge> {
|
|
389
|
+
requirePiLoginProvider(id)
|
|
390
|
+
return this.slot(id).signIn()
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async signOut(id: string): Promise<void> {
|
|
394
|
+
requirePiLoginProvider(id)
|
|
395
|
+
await this.slot(id).signOut()
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async cancel(id: string): Promise<void> {
|
|
399
|
+
requirePiLoginProvider(id)
|
|
400
|
+
await this.slot(id).cancel()
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async submitInput(id: string, value: string): Promise<PiLoginAccountState> {
|
|
404
|
+
requirePiLoginProvider(id)
|
|
405
|
+
return this.slot(id).submitInput(value)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Wait until the named provider's in-flight sign-in settles. */
|
|
409
|
+
async waitUntilSettled(id: string): Promise<void> {
|
|
410
|
+
requirePiLoginProvider(id)
|
|
411
|
+
await this.slot(id).waitUntilSettled()
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async dispose(): Promise<void> {
|
|
415
|
+
await Promise.all([...this.byId.values()].map(slot => slot.dispose()))
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function trustedRequest(req: IncomingMessage): boolean {
|
|
420
|
+
const remote = req.socket.remoteAddress
|
|
421
|
+
if (remote !== '127.0.0.1' && remote !== '::1' && remote !== '::ffff:127.0.0.1') return false
|
|
422
|
+
if (req.headers['sec-fetch-site'] === 'cross-site') return false
|
|
423
|
+
const host = req.headers.host
|
|
424
|
+
if (host === undefined) return false
|
|
425
|
+
const origin = req.headers.origin
|
|
426
|
+
if (origin === undefined) return true
|
|
427
|
+
try {
|
|
428
|
+
return new URL(origin).host === new URL(`http://${host}`).host
|
|
429
|
+
} catch {
|
|
430
|
+
return false
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function trustedProxyRequest(req: IncomingMessage): boolean {
|
|
435
|
+
if (!trustedRequest(req)) return false
|
|
436
|
+
// A proxy endpoint controls where credentials travel. Reject a rebound
|
|
437
|
+
// public hostname even when its connection happens to originate on loopback.
|
|
438
|
+
try {
|
|
439
|
+
const host = new URL(`http://${req.headers.host}`).hostname
|
|
440
|
+
return host === 'localhost' || host === '127.0.0.1' || host === '[::1]'
|
|
441
|
+
} catch { return false }
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function rejectWebRequest(ctx: Context, req: IncomingMessage, res: ServerResponse, proxy = false): boolean {
|
|
445
|
+
const rejection = ctx.connection.requestRejection(req)
|
|
446
|
+
if (rejection !== undefined) {
|
|
447
|
+
json(res, rejection, { error: rejection === 401 ? 'unauthorized' : 'forbidden' })
|
|
448
|
+
return true
|
|
449
|
+
}
|
|
450
|
+
if (proxy ? !trustedProxyRequest(req) : !trustedRequest(req)) {
|
|
451
|
+
json(res, 403, { error: 'forbidden' })
|
|
452
|
+
return true
|
|
453
|
+
}
|
|
454
|
+
return false
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function json(res: ServerResponse, status: number, value: unknown): void {
|
|
458
|
+
res.writeHead(status, {
|
|
459
|
+
'content-type': 'application/json; charset=utf-8',
|
|
460
|
+
'cache-control': 'no-store',
|
|
461
|
+
'x-content-type-options': 'nosniff',
|
|
462
|
+
})
|
|
463
|
+
res.end(JSON.stringify(value))
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async function readJson(req: IncomingMessage): Promise<unknown> {
|
|
467
|
+
const chunks: Buffer[] = []
|
|
468
|
+
let size = 0
|
|
469
|
+
for await (const chunk of req) {
|
|
470
|
+
size += chunk.length
|
|
471
|
+
if (size > 4096) throw new Error('request body too large')
|
|
472
|
+
chunks.push(chunk as Buffer)
|
|
473
|
+
}
|
|
474
|
+
if (chunks.length === 0) return {}
|
|
475
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function providerIdFrom(value: unknown): string {
|
|
479
|
+
if (typeof value !== 'object' || value === null || !('provider' in value) || typeof value.provider !== 'string') {
|
|
480
|
+
throw new Error('expected { "provider": "<id>" }')
|
|
481
|
+
}
|
|
482
|
+
return requirePiLoginProvider(value.provider).id
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function providerInputFrom(value: unknown): { providerId: string; value: string } {
|
|
486
|
+
if (
|
|
487
|
+
typeof value !== 'object'
|
|
488
|
+
|| value === null
|
|
489
|
+
|| !('provider' in value)
|
|
490
|
+
|| typeof value.provider !== 'string'
|
|
491
|
+
|| !('value' in value)
|
|
492
|
+
|| typeof value.value !== 'string'
|
|
493
|
+
) {
|
|
494
|
+
throw new Error('expected { "provider": "<id>", "value": "<credential>" }')
|
|
495
|
+
}
|
|
496
|
+
return {
|
|
497
|
+
providerId: requirePiLoginProvider(value.provider).id,
|
|
498
|
+
value: value.value,
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export interface PiLoginAuthRouteOptions {
|
|
503
|
+
/** Called after a successful sign-in or sign-out so the host can refresh LLM routes. */
|
|
504
|
+
onAuthChanged?: () => void | Promise<void>
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export function registerPiLoginAuthRoutes(
|
|
508
|
+
ctx: Context,
|
|
509
|
+
session: PiLoginSession,
|
|
510
|
+
options: PiLoginAuthRouteOptions = {},
|
|
511
|
+
): void {
|
|
512
|
+
const auth = new PiLoginWebAuth(session)
|
|
513
|
+
const notifyAuthChanged = async (): Promise<void> => {
|
|
514
|
+
await options.onAuthChanged?.()
|
|
515
|
+
}
|
|
516
|
+
ctx.effect(() => {
|
|
517
|
+
const routes = [
|
|
518
|
+
ctx.webServer.register({
|
|
519
|
+
kind: 'exact',
|
|
520
|
+
path: PROXY_SETTINGS_PATH,
|
|
521
|
+
handler: async (req, res) => {
|
|
522
|
+
if (rejectWebRequest(ctx, req, res, true)) return
|
|
523
|
+
if (req.method === 'GET') {
|
|
524
|
+
try { return json(res, 200, await session.proxy.settings.read()) } catch {
|
|
525
|
+
return json(res, 503, { error: 'Network settings could not be read' })
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
|
|
529
|
+
if (req.headers['content-type']?.split(';')[0]?.trim() !== 'application/json') {
|
|
530
|
+
return json(res, 415, { error: 'application/json required' })
|
|
531
|
+
}
|
|
532
|
+
let settings
|
|
533
|
+
try { settings = parseProxySettings(await readJson(req)) } catch {
|
|
534
|
+
return json(res, 400, { error: 'Invalid network settings: use HTTP(S) proxy URLs without credentials or paths' })
|
|
535
|
+
}
|
|
536
|
+
try { json(res, 200, await session.proxy.save(settings)) } catch (error) {
|
|
537
|
+
if (error instanceof ProxySettingsConflict) return json(res, 409, { error: error.message })
|
|
538
|
+
json(res, 503, { error: 'Network settings could not be saved' })
|
|
539
|
+
}
|
|
540
|
+
},
|
|
541
|
+
}),
|
|
542
|
+
ctx.webServer.register({
|
|
543
|
+
kind: 'exact',
|
|
544
|
+
path: OPENROUTER_CATALOG_PATH,
|
|
545
|
+
handler: async (req, res) => {
|
|
546
|
+
if (rejectWebRequest(ctx, req, res)) return
|
|
547
|
+
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' })
|
|
548
|
+
try {
|
|
549
|
+
await session.openRouter.syncAuthentication()
|
|
550
|
+
json(res, 200, session.openRouter.snapshot())
|
|
551
|
+
} catch {
|
|
552
|
+
json(res, 503, { error: 'OpenRouter model catalog unavailable' })
|
|
553
|
+
}
|
|
554
|
+
},
|
|
555
|
+
}),
|
|
556
|
+
ctx.webServer.register({
|
|
557
|
+
kind: 'exact',
|
|
558
|
+
path: OPENROUTER_REFRESH_PATH,
|
|
559
|
+
handler: async (req, res) => {
|
|
560
|
+
if (rejectWebRequest(ctx, req, res)) return
|
|
561
|
+
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
|
|
562
|
+
try {
|
|
563
|
+
await session.openRouter.syncAuthentication()
|
|
564
|
+
if (!session.openRouter.snapshot().connected) {
|
|
565
|
+
return json(res, 409, { error: 'Sign in to OpenRouter first' })
|
|
566
|
+
}
|
|
567
|
+
await session.openRouter.refresh(true)
|
|
568
|
+
json(res, 200, session.openRouter.snapshot())
|
|
569
|
+
} catch {
|
|
570
|
+
json(res, 503, { error: 'OpenRouter model catalog unavailable' })
|
|
571
|
+
}
|
|
572
|
+
},
|
|
573
|
+
}),
|
|
574
|
+
ctx.webServer.register({
|
|
575
|
+
kind: 'exact',
|
|
576
|
+
path: PI_LOGIN_AUTH_STATUS_PATH,
|
|
577
|
+
handler: async (req, res) => {
|
|
578
|
+
if (rejectWebRequest(ctx, req, res)) return
|
|
579
|
+
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' })
|
|
580
|
+
json(res, 200, await auth.status())
|
|
581
|
+
},
|
|
582
|
+
}),
|
|
583
|
+
ctx.webServer.register({
|
|
584
|
+
kind: 'exact',
|
|
585
|
+
path: PI_LOGIN_AUTH_LOGIN_PATH,
|
|
586
|
+
handler: async (req, res) => {
|
|
587
|
+
if (rejectWebRequest(ctx, req, res)) return
|
|
588
|
+
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
|
|
589
|
+
try {
|
|
590
|
+
const challenge = await auth.signIn(providerIdFrom(await readJson(req)))
|
|
591
|
+
if (challenge.kind === 'browser') {
|
|
592
|
+
// OAuth finishes in the browser; refresh LLM routes once the grant lands.
|
|
593
|
+
void auth.waitUntilSettled(challenge.provider).then(async () => {
|
|
594
|
+
await notifyAuthChanged()
|
|
595
|
+
})
|
|
596
|
+
}
|
|
597
|
+
json(res, 200, challenge)
|
|
598
|
+
} catch (error: unknown) {
|
|
599
|
+
json(res, 500, { error: safeMessage(error) })
|
|
600
|
+
}
|
|
601
|
+
},
|
|
602
|
+
}),
|
|
603
|
+
ctx.webServer.register({
|
|
604
|
+
kind: 'exact',
|
|
605
|
+
path: PI_LOGIN_AUTH_CANCEL_PATH,
|
|
606
|
+
handler: async (req, res) => {
|
|
607
|
+
if (rejectWebRequest(ctx, req, res)) return
|
|
608
|
+
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
|
|
609
|
+
try {
|
|
610
|
+
await auth.cancel(providerIdFrom(await readJson(req)))
|
|
611
|
+
json(res, 200, { ok: true })
|
|
612
|
+
} catch (error: unknown) {
|
|
613
|
+
json(res, 500, { error: safeMessage(error) })
|
|
614
|
+
}
|
|
615
|
+
},
|
|
616
|
+
}),
|
|
617
|
+
ctx.webServer.register({
|
|
618
|
+
kind: 'exact',
|
|
619
|
+
path: PI_LOGIN_AUTH_COMPLETE_PATH,
|
|
620
|
+
handler: async (req, res) => {
|
|
621
|
+
if (rejectWebRequest(ctx, req, res)) return
|
|
622
|
+
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
|
|
623
|
+
try {
|
|
624
|
+
const submission = providerInputFrom(await readJson(req))
|
|
625
|
+
const account = await auth.submitInput(submission.providerId, submission.value)
|
|
626
|
+
await notifyAuthChanged()
|
|
627
|
+
json(res, 200, { ok: true, account })
|
|
628
|
+
} catch (error: unknown) {
|
|
629
|
+
json(res, 500, { error: safeMessage(error) })
|
|
630
|
+
}
|
|
631
|
+
},
|
|
632
|
+
}),
|
|
633
|
+
ctx.webServer.register({
|
|
634
|
+
kind: 'exact',
|
|
635
|
+
path: PI_LOGIN_AUTH_LOGOUT_PATH,
|
|
636
|
+
handler: async (req, res) => {
|
|
637
|
+
if (rejectWebRequest(ctx, req, res)) return
|
|
638
|
+
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
|
|
639
|
+
try {
|
|
640
|
+
await auth.signOut(providerIdFrom(await readJson(req)))
|
|
641
|
+
await notifyAuthChanged()
|
|
642
|
+
json(res, 200, { ok: true })
|
|
643
|
+
} catch (error: unknown) {
|
|
644
|
+
json(res, 500, { error: safeMessage(error) })
|
|
645
|
+
}
|
|
646
|
+
},
|
|
647
|
+
}),
|
|
648
|
+
]
|
|
649
|
+
return async () => {
|
|
650
|
+
for (const dispose of routes) dispose()
|
|
651
|
+
await auth.dispose()
|
|
652
|
+
}
|
|
653
|
+
}, 'dsh-oauth-login: Web OAuth routes')
|
|
654
|
+
}
|