Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
+255
View File
@@ -0,0 +1,255 @@
/**
* native-oauth.ts
*
* Pure, electron-free helpers for the desktop's RFC 8252 (OAuth 2.0 for Native
* Apps) login to a gated Hermes gateway: system-browser + loopback redirect +
* PKCE, with tokens returned to the app (never browser session cookies).
*
* Kept standalone (no `import 'electron'`) so it unit-tests with `node --test`
* — same pattern as connection-config.ts. main.ts owns the electron-coupled
* parts (the actual http.Server loopback listener, shell.openExternal, and
* safeStorage keychain writes) and calls these helpers for the pure logic.
*
* Why the gateway brokers the flow (not a direct desktop→IDP client): the
* upstream IDP (Nous Portal) issues a per-gateway-instance client_id and only
* accepts a redirect_uri on the gateway's own origin, so a desktop loopback
* redirect can't be a direct Portal client. Instead the gateway exposes
* /auth/native/{authorize,token,refresh}: it is the authorization server to
* the desktop and an OAuth client to Portal. The desktop still gets the full
* RFC 8252 experience — its own PKCE pair, its own loopback redirect, tokens
* it stores itself.
*
* Capability detection: the gateway advertises supported flows on the public
* /api/status `auth_flows` array. `native_pkce` present ⇒ use this flow;
* absent (older gateway) ⇒ the caller falls back to the embedded-webview
* cookie flow. This is the "observable ladder / compatibility fallback tied to
* an identified older runtime" the desktop guide requires.
*/
import { createHash, randomBytes } from 'node:crypto'
import { type AdvertisedAuthProvider, oauthGuardMayHardFail } from './native-auth-decisions'
// The gateway status field that lists supported auth flows. See
// hermes_cli/web_server.py status handler.
const NATIVE_FLOW_ID = 'native_pkce'
export interface NativePkcePair {
verifier: string
challenge: string
method: 'S256'
}
export interface NativeTokenSet {
accessToken: string
refreshToken: string
expiresAt: number
provider: string
userId: string
}
/** base64url without `=` padding (RFC 7636 §4). */
function b64url(raw: Buffer): string {
return raw.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
/**
* Generate a PKCE verifier/challenge pair (S256). The verifier is 32 random
* bytes base64url-encoded (43 chars, within RFC 7636's 43128 range).
*/
export function generatePkcePair(randomImpl: (n: number) => Buffer = randomBytes): NativePkcePair {
const verifier = b64url(randomImpl(32))
const challenge = b64url(createHash('sha256').update(verifier, 'ascii').digest())
return { verifier, challenge, method: 'S256' }
}
/** A high-entropy CSRF `state` value for the loopback round trip. */
export function generateState(randomImpl: (n: number) => Buffer = randomBytes): string {
return b64url(randomImpl(24))
}
/**
* True if a gateway `/api/status` body advertises the native PKCE flow.
* Tolerant of the field being absent (older gateway) or malformed.
*/
export function statusSupportsNativeFlow(statusBody: any): boolean {
const flows = statusBody && statusBody.auth_flows
return Array.isArray(flows) && flows.includes(NATIVE_FLOW_ID)
}
/**
* Decide the login strategy for a gated gateway from its status body and
* advertised provider capabilities.
*
* Returns 'native' when the gateway advertises native_pkce AND at least one
* non-password provider is available; 'embedded' when all providers are
* password-only, the gateway lacks native_pkce, or forceEmbedded is set.
*
* Provider metadata is discovered from /api/auth/providers (separate from
* /api/status). When absent (older gateway), the decision falls through to
* the auth_flows check — existing compatibility is preserved.
*
* `forceEmbedded` lets a user/setting or an env override pin the legacy flow
* (e.g. a corporate proxy that blocks loopback). Precedence written down here,
* in one place, as a pure function — per the desktop "observable ladder" rule.
*/
export function resolveLoginStrategy(
statusBody: any,
opts: { forceEmbedded?: boolean; providers?: AdvertisedAuthProvider[] } = {}
): 'native' | 'embedded' {
if (opts.forceEmbedded) {
return 'embedded'
}
if (!oauthGuardMayHardFail(opts.providers)) {
return 'embedded'
}
return statusSupportsNativeFlow(statusBody) ? 'native' : 'embedded'
}
/**
* Build the gateway `/auth/native/authorize` URL the system browser opens.
* `redirectUri` is the desktop's loopback callback (127.0.0.1:<port>/...).
* `provider` is optional — omitted lets the gateway pick when it has exactly
* one session provider (the common hosted case).
*/
export function buildNativeAuthorizeUrl(
baseUrl: string,
params: { challenge: string; redirectUri: string; state: string; provider?: string }
): string {
const parsed = new URL(baseUrl)
const prefix = parsed.pathname.replace(/\/+$/, '')
const q = new URLSearchParams({
code_challenge: params.challenge,
code_challenge_method: 'S256',
redirect_uri: params.redirectUri,
state: params.state
})
if (params.provider) {
q.set('provider', params.provider)
}
return `${parsed.protocol}//${parsed.host}${prefix}/auth/native/authorize?${q.toString()}`
}
/** The `/auth/native/token` endpoint URL for a gateway base URL. */
export function nativeTokenUrl(baseUrl: string): string {
const parsed = new URL(baseUrl)
const prefix = parsed.pathname.replace(/\/+$/, '')
return `${parsed.protocol}//${parsed.host}${prefix}/auth/native/token`
}
/** The `/auth/native/refresh` endpoint URL for a gateway base URL. */
export function nativeRefreshUrl(baseUrl: string): string {
const parsed = new URL(baseUrl)
const prefix = parsed.pathname.replace(/\/+$/, '')
return `${parsed.protocol}//${parsed.host}${prefix}/auth/native/refresh`
}
/**
* Parse the loopback redirect the gateway sends the browser to. Returns the
* `code` + `state`, or throws with the gateway's `error` if the flow failed.
* `expectedState` MUST match (CSRF defense — RFC 6749 §10.12); a mismatch
* throws rather than proceeding.
*/
export function parseLoopbackCallback(requestUrl: string, expectedState: string): { code: string } {
// requestUrl is the path+query the loopback server received, e.g.
// "/callback?code=...&state=...". Resolve against a dummy origin to parse.
const parsed = new URL(requestUrl, 'http://127.0.0.1')
const error = parsed.searchParams.get('error')
if (error) {
const desc = parsed.searchParams.get('error_description') || ''
throw new Error(`Gateway rejected native login: ${error}${desc ? ` (${desc})` : ''}`)
}
const code = parsed.searchParams.get('code') || ''
const state = parsed.searchParams.get('state') || ''
if (!code) {
throw new Error('Loopback callback missing authorization code')
}
if (!expectedState || state !== expectedState) {
// Never redeem a code that arrived with a mismatched state — it may be a
// forged callback trying to inject an attacker's code.
throw new Error('Loopback callback state mismatch (possible CSRF)')
}
return { code }
}
/**
* Normalize a `/auth/native/token` (or refresh) JSON response into a
* NativeTokenSet, validating the shape. Throws on a missing/short access
* token so a malformed response fails loudly rather than storing junk.
*/
export function parseTokenResponse(body: any): NativeTokenSet {
const accessToken = String(body?.access_token || '')
if (!accessToken) {
throw new Error('Gateway token response missing access_token')
}
const expiresAt = Number(body?.expires_at)
return {
accessToken,
refreshToken: String(body?.refresh_token || ''),
expiresAt: Number.isFinite(expiresAt) ? expiresAt : 0,
provider: String(body?.provider || ''),
userId: String(body?.user_id || '')
}
}
/**
* Validate a token set loaded from the encrypted local store.
*
* The stored representation is already normalized as NativeTokenSet and
* therefore uses camelCase. Gateway token responses use snake_case and
* remain handled separately by parseTokenResponse().
*/
export function parseStoredTokenSet(body: any): NativeTokenSet {
const accessToken = String(body?.accessToken || '')
if (!accessToken) {
throw new Error('Stored token set missing accessToken')
}
const expiresAt = Number(body?.expiresAt)
return {
accessToken,
refreshToken: String(body?.refreshToken || ''),
expiresAt: Number.isFinite(expiresAt) ? expiresAt : 0,
provider: String(body?.provider || ''),
userId: String(body?.userId || '')
}
}
/**
* True when a stored token set is at/near expiry and should be refreshed
* before use. `skewSeconds` refreshes slightly early to avoid a race where
* the token expires in flight (mirrors the server's 60s cookie floor).
*/
export function tokenNeedsRefresh(
tokens: Pick<NativeTokenSet, 'expiresAt'>,
nowSeconds: number,
skewSeconds = 60
): boolean {
if (!tokens || !Number.isFinite(tokens.expiresAt) || tokens.expiresAt <= 0) {
// Unknown expiry ⇒ treat as needing refresh so we validate before use.
return true
}
return nowSeconds >= tokens.expiresAt - skewSeconds
}
export { NATIVE_FLOW_ID }