@huynle/hub 0.0.0-stage → 0.6.1
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/CHANGELOG.md +269 -0
- package/Dockerfile +51 -0
- package/LICENSE +21 -0
- package/README.md +1029 -2
- package/bin/hub.ts +20 -0
- package/docker/hub-deps.ts +42 -0
- package/package.json +85 -4
- package/rpc.ts +2 -0
- package/scripts/hub-local.sh +28 -0
- package/scripts/io.github.huynle.hub.plist +42 -0
- package/server.ts +3 -0
- package/src/central/assets.ts +74 -0
- package/src/central/auth.ts +271 -0
- package/src/central/config.ts +310 -0
- package/src/central/http.ts +1247 -0
- package/src/central/main.ts +136 -0
- package/src/central/oidc.ts +295 -0
- package/src/central/push-target.ts +97 -0
- package/src/central/push.ts +285 -0
- package/src/central/registry.ts +1153 -0
- package/src/central/speech/models.ts +89 -0
- package/src/central/speech/openai.ts +66 -0
- package/src/central/speech/openrouter.ts +71 -0
- package/src/central/speech/provider.ts +74 -0
- package/src/central/speech/service.ts +168 -0
- package/src/central/speech/settings.ts +375 -0
- package/src/central/spoke-ws.ts +300 -0
- package/src/central/store.ts +653 -0
- package/src/config.ts +170 -0
- package/src/env.ts +63 -0
- package/src/index.ts +236 -0
- package/src/key.ts +17 -0
- package/src/loopback.ts +41 -0
- package/src/migrate.ts +105 -0
- package/src/openrouter.ts +3 -0
- package/src/options.ts +33 -0
- package/src/protocol.ts +650 -0
- package/src/reply.ts +27 -0
- package/src/rpc.ts +98 -0
- package/src/setup.ts +124 -0
- package/src/speakable.ts +35 -0
- package/src/spoke/client.ts +272 -0
- package/src/spoke/conversation.ts +93 -0
- package/src/spoke/enroll.ts +79 -0
- package/src/spoke/herdr.ts +346 -0
- package/src/spoke/listing.ts +108 -0
- package/src/spoke/localhub.ts +287 -0
- package/src/spoke/locations.ts +34 -0
- package/src/spoke/own-server.ts +32 -0
- package/src/spoke/presence.ts +45 -0
- package/src/spoke/singleton.ts +103 -0
- package/src/spoke/spoke.ts +1212 -0
- package/src/spoke/summary.ts +117 -0
- package/src/spoke/timeline.ts +93 -0
- package/src/spoke/tracker.ts +345 -0
- package/src/spoke/watch.ts +78 -0
- package/src/status.ts +232 -0
- package/src/tui-commands.ts +53 -0
- package/src/tui-forms.ts +77 -0
- package/src/tui-presence.ts +75 -0
- package/src/tui.ts +479 -0
- package/src/turns.ts +94 -0
- package/src/voice-command.ts +65 -0
- package/tui.ts +2 -0
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/index-DClBvfLL.css +1 -0
- package/web/dist/assets/index-Djo_yF0a.js +12 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/icon-192.png +0 -0
- package/web/dist/icon-512.png +0 -0
- package/web/dist/icon-maskable-512.png +0 -0
- package/web/dist/icon.svg +18 -0
- package/web/dist/index.html +25 -0
- package/web/dist/manifest.webmanifest +18 -0
- package/web/dist/sw.js +151 -0
- package/web/dist/vad-worklet.js +27 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// The hub process: command line, config, startup log and shutdown. Shared by bin/hub.ts (web/ from
|
|
2
|
+
// disk) and the compiled binaries (web/ embedded, see scripts/gen-web-entry.ts).
|
|
3
|
+
import type { WebAssets } from "./assets"
|
|
4
|
+
import { PAIR_TTL_MS } from "./auth"
|
|
5
|
+
import { isLoopbackURL } from "../loopback"
|
|
6
|
+
import { loadCentralConfig, type CentralConfig } from "./config"
|
|
7
|
+
import { startCentral } from "./http"
|
|
8
|
+
|
|
9
|
+
export type HubArgs = { readonly action: "run" | "version" | "help" } | { readonly action: "error"; readonly message: string }
|
|
10
|
+
|
|
11
|
+
export function parseHubArgs(argv: readonly string[]): HubArgs {
|
|
12
|
+
if (argv.length === 0) return { action: "run" }
|
|
13
|
+
const [first] = argv
|
|
14
|
+
if (argv.length === 1 && (first === "--version" || first === "-v")) return { action: "version" }
|
|
15
|
+
if (argv.length === 1 && (first === "--help" || first === "-h")) return { action: "help" }
|
|
16
|
+
return { action: "error", message: `Unknown argument: ${argv.join(" ")}. The hub is configured with env vars and hub.json; see --help.` }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function hubHelp(version: string): string {
|
|
20
|
+
return `hub-server ${version}
|
|
21
|
+
|
|
22
|
+
Hub: the central hub your AI agent harnesses connect to (OpenCode first). Serves the web console,
|
|
23
|
+
pairs phones and browsers, enrolls machines, and does push notifications and (optional) Voice speech.
|
|
24
|
+
|
|
25
|
+
Usage: hub-server [--help | --version] (also installed as opencode-hub and opencode-voice-hub)
|
|
26
|
+
|
|
27
|
+
Configuration: environment variables over ~/.config/hub/hub.json ($HUB_CONFIG).
|
|
28
|
+
HUB_HOST / HUB_PORT listen address (default 127.0.0.1 / 4097)
|
|
29
|
+
HUB_PUBLIC_URL origin used in links and cookies (default http://<host>:<port>)
|
|
30
|
+
HUB_STATE_DIR machines, devices, keys (default ~/.local/state/hub)
|
|
31
|
+
OPENROUTER_API_KEY default speech key; or set speech providers in the web app (Settings → Speech)
|
|
32
|
+
HUB_CONFIG path of hub.json
|
|
33
|
+
|
|
34
|
+
Multi-user (sign in with Google or another OpenID Connect provider):
|
|
35
|
+
HUB_AUTH=oidc default "pairing" (one owner)
|
|
36
|
+
HUB_OIDC_ISSUER default https://accounts.google.com
|
|
37
|
+
HUB_OIDC_CLIENT_ID OAuth client ID (required)
|
|
38
|
+
HUB_OIDC_CLIENT_SECRET OAuth client secret (required; never logged)
|
|
39
|
+
HUB_OIDC_ALLOWED who may sign in: emails and/or @domain entries, comma separated (required)
|
|
40
|
+
HUB_ADMIN_EMAILS admin emails, comma separated (at least one)
|
|
41
|
+
Register <public URL>/auth/callback as the redirect URI.
|
|
42
|
+
|
|
43
|
+
The former VOICE_* names (opencode-voice) still work, with a deprecation warning; so do the
|
|
44
|
+
opencode-hub and opencode-voice config and state directories (copied once on first start).
|
|
45
|
+
While no device is paired, the log shows a pairing code for the first one (open <public URL>/pair).
|
|
46
|
+
Docs: https://github.com/huynle/hub#running-the-hub
|
|
47
|
+
`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Startup log lines about sign-in (never the client secret). Pairing mode has none. */
|
|
51
|
+
export function authStartupLines(config: CentralConfig): { info: string[]; warnings: string[] } {
|
|
52
|
+
const auth = config.auth
|
|
53
|
+
if (auth.mode !== "oidc") return { info: [], warnings: [] }
|
|
54
|
+
const entries = auth.allowed.length
|
|
55
|
+
const admins = auth.admins.length
|
|
56
|
+
const info = [
|
|
57
|
+
`multi-user: sign-in with ${auth.issuer} (client ${auth.clientId}); redirect URI ${config.publicURL}/auth/callback`,
|
|
58
|
+
`multi-user: ${entries} allow-list ${entries === 1 ? "entry" : "entries"}, ${admins} admin${admins === 1 ? "" : "s"}`,
|
|
59
|
+
]
|
|
60
|
+
const warnings: string[] = []
|
|
61
|
+
if (!config.publicURL.startsWith("https://") && !isLoopbackURL(config.publicURL)) {
|
|
62
|
+
warnings.push(`HUB_PUBLIC_URL ${config.publicURL} is not https: sign-in cookies travel unencrypted and most providers refuse the redirect URI`)
|
|
63
|
+
}
|
|
64
|
+
if (auth.insecureIssuer) warnings.push(`HUB_OIDC_INSECURE_ISSUER is set: plain-http issuer ${auth.issuer} allowed (development only)`)
|
|
65
|
+
return { info, warnings }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface RunHubOptions {
|
|
69
|
+
readonly web: WebAssets
|
|
70
|
+
readonly version: string
|
|
71
|
+
readonly argv?: readonly string[]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Runs the hub until SIGINT/SIGTERM (or answers --help / --version). */
|
|
75
|
+
export function runHub(options: RunHubOptions): void {
|
|
76
|
+
const args = parseHubArgs(options.argv ?? process.argv.slice(2))
|
|
77
|
+
if (args.action === "version") {
|
|
78
|
+
console.log(options.version)
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
if (args.action === "help") {
|
|
82
|
+
console.log(hubHelp(options.version))
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
if (args.action === "error") {
|
|
86
|
+
console.error(`[hub] ${args.message}`)
|
|
87
|
+
process.exit(2)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let loaded: ReturnType<typeof loadCentralConfig>
|
|
91
|
+
try {
|
|
92
|
+
loaded = loadCentralConfig()
|
|
93
|
+
} catch (cause) {
|
|
94
|
+
console.error(`[hub] ${cause instanceof Error ? cause.message : cause}`)
|
|
95
|
+
process.exit(1)
|
|
96
|
+
}
|
|
97
|
+
for (const warning of loaded.warnings) console.warn(`[hub] warning: ${warning}`)
|
|
98
|
+
const config = loaded.config
|
|
99
|
+
const central = startCentral({ config, web: options.web, version: options.version })
|
|
100
|
+
|
|
101
|
+
console.log(`[hub] Hub ${options.version} listening on ${central.url} (public URL ${central.publicURL})`)
|
|
102
|
+
console.log(`[hub] state: ${config.stateDir}`)
|
|
103
|
+
console.log(`[hub] config: ${loaded.loaded ? loaded.path : `${loaded.path} (not found; using env and defaults)`}`)
|
|
104
|
+
const authLines = authStartupLines(config)
|
|
105
|
+
for (const line of authLines.info) console.log(`[hub] ${line}`)
|
|
106
|
+
for (const line of authLines.warnings) console.warn(`[hub] warning: ${line}`)
|
|
107
|
+
const speech = central.speechSettings.view()
|
|
108
|
+
if (speech.ready.reason) {
|
|
109
|
+
console.log(`[hub] speech is not set up yet: ${speech.ready.reason} (or OPENROUTER_API_KEY, or speech.stt/tts in hub.json)`)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// While no device is paired, keep a fresh bootstrap pairing code in the log.
|
|
113
|
+
const bootstrap = () => {
|
|
114
|
+
const code = central.auth.bootstrapCode()
|
|
115
|
+
if (code) console.log(`[hub] Pair your first device at ${central.publicURL}/pair with code ${code.code}`)
|
|
116
|
+
return code !== undefined
|
|
117
|
+
}
|
|
118
|
+
let timer: ReturnType<typeof setInterval> | undefined
|
|
119
|
+
if (bootstrap()) {
|
|
120
|
+
timer = setInterval(() => {
|
|
121
|
+
if (!bootstrap()) clearInterval(timer)
|
|
122
|
+
}, PAIR_TTL_MS - 5_000)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let stopping = false
|
|
126
|
+
const shutdown = async (signal: string) => {
|
|
127
|
+
if (stopping) return
|
|
128
|
+
stopping = true
|
|
129
|
+
console.log(`[hub] ${signal}: shutting down`)
|
|
130
|
+
clearInterval(timer)
|
|
131
|
+
await central.stop()
|
|
132
|
+
process.exit(0)
|
|
133
|
+
}
|
|
134
|
+
process.on("SIGTERM", () => void shutdown("SIGTERM"))
|
|
135
|
+
process.on("SIGINT", () => void shutdown("SIGINT"))
|
|
136
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
// OpenID Connect sign-in (authorization code flow with PKCE S256, state and nonce) for multi-user hubs.
|
|
2
|
+
// The ID token comes straight from the token endpoint over TLS (confidential client, client_secret_post),
|
|
3
|
+
// so its signature is not checked (OIDC Core 3.1.3.7, item 6); its claims are. Pending sign-ins live in
|
|
4
|
+
// memory only (a restart invalidates them) and are single use.
|
|
5
|
+
import { createHash, randomBytes } from "node:crypto"
|
|
6
|
+
import type { OidcConfig } from "./config"
|
|
7
|
+
|
|
8
|
+
export const OIDC_COOKIE = "voice_oidc"
|
|
9
|
+
export const OIDC_PENDING_TTL_MS = 10 * 60_000
|
|
10
|
+
/** Discovery documents are re-fetched after this long. */
|
|
11
|
+
export const DISCOVERY_CACHE_MS = 60 * 60_000
|
|
12
|
+
/** Clock skew tolerated on `exp`. */
|
|
13
|
+
const SKEW_SECONDS = 60
|
|
14
|
+
/** Most pending sign-ins kept (the oldest are dropped): /auth/login is public. */
|
|
15
|
+
const MAX_PENDING = 1000
|
|
16
|
+
const FETCH_TIMEOUT_MS = 10_000
|
|
17
|
+
const MAX_NEXT_CHARS = 1024
|
|
18
|
+
|
|
19
|
+
/** A failed sign-in step; `message` is safe to show, `reason` goes to the audit log. */
|
|
20
|
+
export class OidcError extends Error {
|
|
21
|
+
constructor(
|
|
22
|
+
readonly status: number,
|
|
23
|
+
message: string,
|
|
24
|
+
readonly reason: string,
|
|
25
|
+
/** The state was valid: the attempt is worth an audit entry. */
|
|
26
|
+
readonly audited = false,
|
|
27
|
+
) {
|
|
28
|
+
super(message)
|
|
29
|
+
this.name = "OidcError"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface OidcIdentity {
|
|
34
|
+
readonly sub: string
|
|
35
|
+
/** Lowercased, verified. */
|
|
36
|
+
readonly email: string
|
|
37
|
+
readonly name?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface Discovery {
|
|
41
|
+
readonly issuer: string
|
|
42
|
+
readonly authorization_endpoint: string
|
|
43
|
+
readonly token_endpoint: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface Pending {
|
|
47
|
+
readonly nonce: string
|
|
48
|
+
readonly verifier: string
|
|
49
|
+
readonly next: string
|
|
50
|
+
readonly expiresAt: number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface OidcClientOptions {
|
|
54
|
+
readonly config: OidcConfig
|
|
55
|
+
/** Hub origin: the redirect URI is <publicURL>/auth/callback. */
|
|
56
|
+
readonly publicURL: string
|
|
57
|
+
readonly fetch?: typeof globalThis.fetch
|
|
58
|
+
readonly now?: () => number
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const random = () => randomBytes(32).toString("base64url")
|
|
62
|
+
|
|
63
|
+
/** A same-origin path (with query and fragment) to land on after sign-in; anything else becomes "/". */
|
|
64
|
+
export function safeNext(raw: string | null | undefined): string {
|
|
65
|
+
if (!raw || raw.length > MAX_NEXT_CHARS) return "/"
|
|
66
|
+
if (!raw.startsWith("/") || raw.startsWith("//") || /[\\\x00-\x20\x7f]/.test(raw)) return "/"
|
|
67
|
+
const base = "http://hub.invalid"
|
|
68
|
+
try {
|
|
69
|
+
const url = new URL(raw, base)
|
|
70
|
+
if (url.origin !== base) return "/"
|
|
71
|
+
const path = `${url.pathname}${url.search}${url.hash}`
|
|
72
|
+
return path.startsWith("//") ? "/" : path
|
|
73
|
+
} catch {
|
|
74
|
+
return "/"
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A short device name from a user agent, e.g. "Safari on iPhone". */
|
|
79
|
+
export function deviceName(userAgent: string | null | undefined): string {
|
|
80
|
+
const ua = userAgent ?? ""
|
|
81
|
+
if (!ua) return "Browser"
|
|
82
|
+
const os = /iPhone/.test(ua)
|
|
83
|
+
? "iPhone"
|
|
84
|
+
: /iPad/.test(ua)
|
|
85
|
+
? "iPad"
|
|
86
|
+
: /Android/.test(ua)
|
|
87
|
+
? "Android"
|
|
88
|
+
: /Macintosh|Mac OS X/.test(ua)
|
|
89
|
+
? "Mac"
|
|
90
|
+
: /Windows/.test(ua)
|
|
91
|
+
? "Windows"
|
|
92
|
+
: /Linux|X11/.test(ua)
|
|
93
|
+
? "Linux"
|
|
94
|
+
: undefined
|
|
95
|
+
const browser = /Edg\//.test(ua)
|
|
96
|
+
? "Edge"
|
|
97
|
+
: /Firefox\/|FxiOS/.test(ua)
|
|
98
|
+
? "Firefox"
|
|
99
|
+
: /Chrome\/|CriOS/.test(ua)
|
|
100
|
+
? "Chrome"
|
|
101
|
+
: /Safari\//.test(ua)
|
|
102
|
+
? "Safari"
|
|
103
|
+
: "Browser"
|
|
104
|
+
return os ? `${browser} on ${os}` : browser
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The value of one cookie in a Cookie header. */
|
|
108
|
+
export function cookieValue(header: string | null, name: string): string | undefined {
|
|
109
|
+
for (const part of (header ?? "").split(";")) {
|
|
110
|
+
const [key, ...rest] = part.trim().split("=")
|
|
111
|
+
if (key === name) return rest.join("=")
|
|
112
|
+
}
|
|
113
|
+
return undefined
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function decodePayload(idToken: unknown): Record<string, unknown> {
|
|
117
|
+
const parts = typeof idToken === "string" ? idToken.split(".") : []
|
|
118
|
+
if (parts.length !== 3 || !parts[1]) throw new OidcError(401, "The sign-in provider sent an invalid ID token.", "id_token malformed", true)
|
|
119
|
+
try {
|
|
120
|
+
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as unknown
|
|
121
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) throw new Error("not an object")
|
|
122
|
+
return payload as Record<string, unknown>
|
|
123
|
+
} catch {
|
|
124
|
+
throw new OidcError(401, "The sign-in provider sent an invalid ID token.", "id_token malformed", true)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export class OidcClient {
|
|
129
|
+
private readonly config: OidcConfig
|
|
130
|
+
private readonly fetch: typeof globalThis.fetch
|
|
131
|
+
private readonly now: () => number
|
|
132
|
+
private readonly pending = new Map<string, Pending>()
|
|
133
|
+
private discovery?: { at: number; doc: Discovery }
|
|
134
|
+
readonly redirectURI: string
|
|
135
|
+
|
|
136
|
+
constructor(options: OidcClientOptions) {
|
|
137
|
+
this.config = options.config
|
|
138
|
+
this.fetch = options.fetch ?? globalThis.fetch
|
|
139
|
+
this.now = options.now ?? Date.now
|
|
140
|
+
this.redirectURI = `${options.publicURL}/auth/callback`
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Starts a sign-in: the provider URL to redirect to and the state for the voice_oidc cookie. */
|
|
144
|
+
async login(next: string | null): Promise<{ location: string; state: string }> {
|
|
145
|
+
const doc = await this.discover()
|
|
146
|
+
this.sweep()
|
|
147
|
+
const state = random()
|
|
148
|
+
const nonce = random()
|
|
149
|
+
const verifier = random()
|
|
150
|
+
while (this.pending.size >= MAX_PENDING) this.pending.delete(this.pending.keys().next().value!)
|
|
151
|
+
this.pending.set(state, { nonce, verifier, next: safeNext(next), expiresAt: this.now() + OIDC_PENDING_TTL_MS })
|
|
152
|
+
const url = new URL(doc.authorization_endpoint)
|
|
153
|
+
url.searchParams.set("response_type", "code")
|
|
154
|
+
url.searchParams.set("client_id", this.config.clientId)
|
|
155
|
+
url.searchParams.set("redirect_uri", this.redirectURI)
|
|
156
|
+
url.searchParams.set("scope", "openid email profile")
|
|
157
|
+
url.searchParams.set("state", state)
|
|
158
|
+
url.searchParams.set("nonce", nonce)
|
|
159
|
+
url.searchParams.set("code_challenge", createHash("sha256").update(verifier).digest("base64url"))
|
|
160
|
+
url.searchParams.set("code_challenge_method", "S256")
|
|
161
|
+
return { location: url.toString(), state }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Finishes a sign-in from the callback's query and the voice_oidc cookie. Throws OidcError. The
|
|
166
|
+
* pending entry is consumed whatever the outcome once the state matches the cookie.
|
|
167
|
+
*/
|
|
168
|
+
async callback(params: URLSearchParams, cookieState: string | undefined): Promise<{ identity: OidcIdentity; next: string }> {
|
|
169
|
+
this.sweep()
|
|
170
|
+
const state = params.get("state") ?? ""
|
|
171
|
+
const pending = state && cookieState === state ? this.pending.get(state) : undefined
|
|
172
|
+
if (!pending) {
|
|
173
|
+
throw new OidcError(400, "This sign-in has expired or was already used. Please sign in again.", "state invalid")
|
|
174
|
+
}
|
|
175
|
+
this.pending.delete(state)
|
|
176
|
+
const providerError = params.get("error")
|
|
177
|
+
if (providerError) {
|
|
178
|
+
const description = params.get("error_description")
|
|
179
|
+
throw new OidcError(400, `The sign-in provider reported: ${providerError}${description ? ` (${description})` : ""}`, `provider error ${providerError.slice(0, 64)}`, true)
|
|
180
|
+
}
|
|
181
|
+
const code = params.get("code")
|
|
182
|
+
if (!code) throw new OidcError(400, "The sign-in provider sent no authorization code.", "no code", true)
|
|
183
|
+
|
|
184
|
+
const doc = await this.discover()
|
|
185
|
+
let tokens: Record<string, unknown>
|
|
186
|
+
try {
|
|
187
|
+
const response = await this.fetch(doc.token_endpoint, {
|
|
188
|
+
method: "POST",
|
|
189
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
190
|
+
body: new URLSearchParams({
|
|
191
|
+
grant_type: "authorization_code",
|
|
192
|
+
code,
|
|
193
|
+
redirect_uri: this.redirectURI,
|
|
194
|
+
client_id: this.config.clientId,
|
|
195
|
+
client_secret: this.config.clientSecret,
|
|
196
|
+
code_verifier: pending.verifier,
|
|
197
|
+
}),
|
|
198
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
199
|
+
})
|
|
200
|
+
if (!response.ok) throw new Error(`token endpoint answered ${response.status}`)
|
|
201
|
+
tokens = (await response.json()) as Record<string, unknown>
|
|
202
|
+
} catch (cause) {
|
|
203
|
+
throw new OidcError(502, "Could not complete sign-in with the provider. Please try again.", `token exchange failed: ${cause instanceof Error ? cause.message : cause}`, true)
|
|
204
|
+
}
|
|
205
|
+
const identity = this.validate(decodePayload(tokens?.id_token), doc, pending.nonce)
|
|
206
|
+
return { identity, next: pending.next }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private validate(claims: Record<string, unknown>, doc: Discovery, nonce: string): OidcIdentity {
|
|
210
|
+
const reject = (reason: string, status = 401, message = "The sign-in could not be verified. Please sign in again."): never => {
|
|
211
|
+
throw new OidcError(status, message, reason, true)
|
|
212
|
+
}
|
|
213
|
+
const issuers = new Set([doc.issuer])
|
|
214
|
+
// Google documents both forms for `iss`.
|
|
215
|
+
if (doc.issuer === "https://accounts.google.com") issuers.add("accounts.google.com")
|
|
216
|
+
if (typeof claims.iss !== "string" || !issuers.has(claims.iss)) reject("iss mismatch")
|
|
217
|
+
const aud = claims.aud
|
|
218
|
+
const audiences = typeof aud === "string" ? [aud] : Array.isArray(aud) ? aud : []
|
|
219
|
+
if (!audiences.includes(this.config.clientId)) reject("aud mismatch")
|
|
220
|
+
if ((audiences.length > 1 || claims.azp !== undefined) && claims.azp !== this.config.clientId) reject("azp mismatch")
|
|
221
|
+
if (typeof claims.exp !== "number" || !Number.isFinite(claims.exp) || claims.exp + SKEW_SECONDS <= this.now() / 1000) reject("token expired")
|
|
222
|
+
if (typeof claims.nonce !== "string" || claims.nonce !== nonce) reject("nonce mismatch")
|
|
223
|
+
if (typeof claims.sub !== "string" || !claims.sub) reject("no sub")
|
|
224
|
+
const email = typeof claims.email === "string" ? claims.email.trim().toLowerCase() : ""
|
|
225
|
+
if (!email) reject("no email", 403, "Your account did not share an email address.")
|
|
226
|
+
if (claims.email_verified !== true && claims.email_verified !== "true") reject("email not verified", 403, "Your email address is not verified with the sign-in provider.")
|
|
227
|
+
const name = typeof claims.name === "string" && claims.name.trim() ? claims.name.trim().slice(0, 100) : undefined
|
|
228
|
+
return { sub: claims.sub as string, email, ...(name ? { name } : {}) }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private async discover(): Promise<Discovery> {
|
|
232
|
+
if (this.discovery && this.now() - this.discovery.at < DISCOVERY_CACHE_MS) return this.discovery.doc
|
|
233
|
+
const issuer = this.config.issuer
|
|
234
|
+
let raw: Record<string, unknown>
|
|
235
|
+
try {
|
|
236
|
+
const response = await this.fetch(`${issuer}/.well-known/openid-configuration`, {
|
|
237
|
+
headers: { accept: "application/json" },
|
|
238
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
239
|
+
})
|
|
240
|
+
if (!response.ok) throw new Error(`answered ${response.status}`)
|
|
241
|
+
raw = (await response.json()) as Record<string, unknown>
|
|
242
|
+
} catch (cause) {
|
|
243
|
+
throw new OidcError(502, "The sign-in provider is not reachable right now. Please try again later.", `discovery failed: ${cause instanceof Error ? cause.message : cause}`)
|
|
244
|
+
}
|
|
245
|
+
const fail = (what: string): never => {
|
|
246
|
+
throw new OidcError(502, "The sign-in provider is misconfigured.", `discovery: ${what}`)
|
|
247
|
+
}
|
|
248
|
+
if (typeof raw.issuer !== "string" || raw.issuer.replace(/\/+$/, "") !== issuer) fail("issuer mismatch")
|
|
249
|
+
for (const key of ["authorization_endpoint", "token_endpoint"] as const) {
|
|
250
|
+
if (!this.endpointOK(raw[key])) fail(`${key} invalid`)
|
|
251
|
+
}
|
|
252
|
+
const doc: Discovery = {
|
|
253
|
+
issuer: raw.issuer as string,
|
|
254
|
+
authorization_endpoint: raw.authorization_endpoint as string,
|
|
255
|
+
token_endpoint: raw.token_endpoint as string,
|
|
256
|
+
}
|
|
257
|
+
this.discovery = { at: this.now(), doc }
|
|
258
|
+
return doc
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** https, or http on the (dev-only) insecure loopback issuer's host. */
|
|
262
|
+
private endpointOK(value: unknown): boolean {
|
|
263
|
+
if (typeof value !== "string") return false
|
|
264
|
+
try {
|
|
265
|
+
const url = new URL(value)
|
|
266
|
+
if (url.protocol === "https:") return true
|
|
267
|
+
return url.protocol === "http:" && this.config.insecureIssuer === true && url.origin === new URL(this.config.issuer).origin
|
|
268
|
+
} catch {
|
|
269
|
+
return false
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private sweep(): void {
|
|
274
|
+
const t = this.now()
|
|
275
|
+
for (const [state, entry] of this.pending) if (entry.expiresAt <= t) this.pending.delete(state)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const ESCAPES: Record<string, string> = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }
|
|
280
|
+
export const escapeHTML = (text: string) => text.replace(/[&<>"']/g, (c) => ESCAPES[c]!)
|
|
281
|
+
|
|
282
|
+
/** A small standalone HTML page for sign-in results (no scripts, nothing external). */
|
|
283
|
+
export function authPage(status: number, title: string, message: string, headers: HeadersInit = {}): Response {
|
|
284
|
+
const html = `<!doctype html>
|
|
285
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
286
|
+
<title>${escapeHTML(title)} · Hub</title>
|
|
287
|
+
<style>body{font:16px/1.5 system-ui,sans-serif;max-width:32rem;margin:15vh auto;padding:0 1rem;color:#222}a{color:#0a58ca}</style>
|
|
288
|
+
</head><body><h1>${escapeHTML(title)}</h1><p>${escapeHTML(message)}</p><p><a href="/">Back to Hub</a></p></body></html>
|
|
289
|
+
`
|
|
290
|
+
const h = new Headers(headers)
|
|
291
|
+
h.set("content-type", "text/html; charset=utf-8")
|
|
292
|
+
h.set("cache-control", "no-store")
|
|
293
|
+
h.set("content-security-policy", "default-src 'none'; style-src 'unsafe-inline'")
|
|
294
|
+
return new Response(html, { status, headers: h })
|
|
295
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Push endpoints come from browsers, and the hub POSTs to them: without checks a subscription could make the
|
|
2
|
+
// hub send requests into its own network (SSRF). An endpoint must be https on a public hostname; hosts are
|
|
3
|
+
// resolved at subscribe time and rejected when any address is loopback, private, link-local, CGNAT, ULA,
|
|
4
|
+
// unspecified or multicast. Real push services (FCM, Mozilla, Apple, WNS) are public names on public addresses.
|
|
5
|
+
import { lookup } from "node:dns/promises"
|
|
6
|
+
import { isIPv4, isIPv6 } from "node:net"
|
|
7
|
+
|
|
8
|
+
export type ResolveHost = (host: string) => Promise<readonly string[]>
|
|
9
|
+
|
|
10
|
+
/** Every address the system resolver returns for a host. */
|
|
11
|
+
export const resolveHost: ResolveHost = async (host) => (await lookup(host, { all: true, verbatim: true })).map((a) => a.address)
|
|
12
|
+
|
|
13
|
+
/** IPv4 [network, prefix length] blocks that never reach a public push service. */
|
|
14
|
+
const V4_BLOCKED: ReadonlyArray<readonly [string, number]> = [
|
|
15
|
+
["0.0.0.0", 8], // "this network", unspecified
|
|
16
|
+
["10.0.0.0", 8], // private
|
|
17
|
+
["100.64.0.0", 10], // CGNAT
|
|
18
|
+
["127.0.0.0", 8], // loopback
|
|
19
|
+
["169.254.0.0", 16], // link-local (cloud metadata)
|
|
20
|
+
["172.16.0.0", 12], // private
|
|
21
|
+
["192.0.0.0", 24], // IETF protocol assignments
|
|
22
|
+
["192.168.0.0", 16], // private
|
|
23
|
+
["198.18.0.0", 15], // benchmarking
|
|
24
|
+
["224.0.0.0", 4], // multicast
|
|
25
|
+
["240.0.0.0", 4], // reserved, broadcast
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
const v4Number = (ip: string): number => ip.split(".").reduce((n, part) => n * 256 + Number(part), 0)
|
|
29
|
+
|
|
30
|
+
function privateV4(ip: string): boolean {
|
|
31
|
+
const n = v4Number(ip)
|
|
32
|
+
return V4_BLOCKED.some(([net, bits]) => Math.floor(n / 2 ** (32 - bits)) === Math.floor(v4Number(net) / 2 ** (32 - bits)))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The eight 16-bit groups of an IPv6 address (with any embedded IPv4 tail converted). */
|
|
36
|
+
function v6Groups(ip: string): number[] {
|
|
37
|
+
let text = ip.toLowerCase().replace(/%.*$/, "")
|
|
38
|
+
const tail = /(\d+\.\d+\.\d+\.\d+)$/.exec(text)
|
|
39
|
+
if (tail) {
|
|
40
|
+
const n = v4Number(tail[1]!)
|
|
41
|
+
text = `${text.slice(0, -tail[1]!.length)}${(n >>> 16).toString(16)}:${(n & 0xffff).toString(16)}`
|
|
42
|
+
}
|
|
43
|
+
const [head, rest] = text.split("::") as [string, string | undefined]
|
|
44
|
+
const left = head ? head.split(":") : []
|
|
45
|
+
const right = rest === undefined ? [] : rest ? rest.split(":") : []
|
|
46
|
+
const fill = rest === undefined ? [] : Array(8 - left.length - right.length).fill("0")
|
|
47
|
+
return [...left, ...fill, ...right].map((g) => parseInt(g, 16))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** True for addresses a push endpoint must not resolve to. Anything that is not an IP counts as private. */
|
|
51
|
+
export function isPrivateAddress(address: string): boolean {
|
|
52
|
+
const ip = address.trim().replace(/^\[(.*)\]$/, "$1")
|
|
53
|
+
if (isIPv4(ip)) return privateV4(ip)
|
|
54
|
+
if (!isIPv6(ip)) return true
|
|
55
|
+
const g = v6Groups(ip)
|
|
56
|
+
const first = g[0]!
|
|
57
|
+
if (g.slice(0, 5).every((x) => x === 0) && (g[5] === 0xffff || g[5] === 0)) {
|
|
58
|
+
// IPv4-mapped (::ffff:a.b.c.d) and IPv4-compatible (::a.b.c.d, which includes :: and ::1).
|
|
59
|
+
if (g[5] === 0 && g[6] === 0 && (g[7] === 0 || g[7] === 1)) return true
|
|
60
|
+
return privateV4(`${g[6]! >> 8}.${g[6]! & 0xff}.${g[7]! >> 8}.${g[7]! & 0xff}`)
|
|
61
|
+
}
|
|
62
|
+
if (first === 0x64 && g[1] === 0xff9b) return privateV4(`${g[6]! >> 8}.${g[6]! & 0xff}.${g[7]! >> 8}.${g[7]! & 0xff}`) // NAT64
|
|
63
|
+
return (
|
|
64
|
+
(first & 0xfe00) === 0xfc00 || // ULA fc00::/7
|
|
65
|
+
(first & 0xffc0) === 0xfe80 || // link-local fe80::/10
|
|
66
|
+
(first & 0xffc0) === 0xfec0 || // site-local fec0::/10 (deprecated)
|
|
67
|
+
(first & 0xff00) === 0xff00 // multicast
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const LOCAL_SUFFIXES = [".localhost", ".local", ".internal", ".localdomain", ".home.arpa", ".lan"]
|
|
72
|
+
|
|
73
|
+
/** Why an endpoint is not acceptable for Web Push, or undefined when it is. */
|
|
74
|
+
export async function pushEndpointProblem(endpoint: string, resolve: ResolveHost): Promise<string | undefined> {
|
|
75
|
+
let url: URL
|
|
76
|
+
try {
|
|
77
|
+
url = new URL(endpoint)
|
|
78
|
+
} catch {
|
|
79
|
+
return "Push endpoint is not a URL"
|
|
80
|
+
}
|
|
81
|
+
if (url.protocol !== "https:") return "Push endpoint must be https"
|
|
82
|
+
if (url.username || url.password) return "Push endpoint must not carry credentials"
|
|
83
|
+
const host = url.hostname.toLowerCase().replace(/^\[(.*)\]$/, "$1").replace(/\.$/, "")
|
|
84
|
+
if (isIPv4(host) || isIPv6(host)) return isPrivateAddress(host) ? "Push endpoint is a private or local address" : undefined
|
|
85
|
+
if (host === "localhost" || !host.includes(".") || LOCAL_SUFFIXES.some((suffix) => host.endsWith(suffix))) {
|
|
86
|
+
return "Push endpoint is a local hostname"
|
|
87
|
+
}
|
|
88
|
+
let addresses: readonly string[]
|
|
89
|
+
try {
|
|
90
|
+
addresses = await resolve(host)
|
|
91
|
+
} catch {
|
|
92
|
+
return `Push endpoint host ${host} does not resolve`
|
|
93
|
+
}
|
|
94
|
+
if (addresses.length === 0) return `Push endpoint host ${host} does not resolve`
|
|
95
|
+
if (addresses.some(isPrivateAddress)) return "Push endpoint resolves to a private or local address"
|
|
96
|
+
return undefined
|
|
97
|
+
}
|