diff --git a/.env.example b/.env.example index a7a246a..389df60 100644 --- a/.env.example +++ b/.env.example @@ -35,6 +35,11 @@ CORS_ORIGINS=https://app.jetkvm.com,http://localhost:5173 # Allowed account emails, split by comma (leave empty to allow all) ALLOWED_IDENTITIES= +# URL schemes of native apps that may sign in through the system browser, split by comma +# (e.g. jetpilot). The app opens /login?returnTo=://..., receives a one-time code on +# that URL after sign-in, and exchanges it at POST /auth/exchange. Leave empty to disable. +NATIVE_APP_SCHEMES= + # Real IP Header for the reverse proxy (e.g. X-Real-IP), leave empty if not needed REAL_IP_HEADER= diff --git a/README.md b/README.md index 0c431eb..776917f 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,14 @@ docker compose up -d The app will be available on port 3000. Configure a reverse proxy (nginx, Caddy, etc.) for TLS termination. +### Native app sign-in + +Native clients can't read the session cookie out of the system browser, so `NATIVE_APP_SCHEMES` lets an app on a registered URL scheme (e.g. `jetpilot`) sign in through the normal login page: + +1. The app opens `${APP_HOSTNAME}/login?returnTo=jetpilot://auth` in the system browser (passkeys and password managers work there). +2. After sign-in, `/oidc/callback` redirects to `jetpilot://auth?code=…` with a single-use code valid for five minutes, and clears the browser session. +3. The app calls `POST /auth/exchange` with `{ "code": "…" }` and stores the `session` / `session.sig` cookies from the response. + ### Updating ```bash diff --git a/src/index.ts b/src/index.ts index b6fdecb..f180570 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import 'dotenv/config'; import * as Devices from "./devices"; import * as OIDC from "./oidc"; +import * as NativeAuth from "./native-auth"; import * as Webrtc from "./webrtc"; import * as Releases from "./releases"; @@ -47,6 +48,7 @@ declare global { ICE_SERVERS: string; ALLOWED_IDENTITIES?: string; + NATIVE_APP_SCHEMES?: string; } } } @@ -137,6 +139,7 @@ app.post( app.post("/oidc/google", OIDC.Google); app.get("/oidc/callback_o", OIDC.Callback); +app.post("/auth/exchange", NativeAuth.Exchange); app.get("/oidc/callback", (req, res) => { /* * We set the session cookie in the /oidc/google route as a part of 302 redirect to the OIDC login page diff --git a/src/native-auth.ts b/src/native-auth.ts new file mode 100644 index 0000000..67017bc --- /dev/null +++ b/src/native-auth.ts @@ -0,0 +1,89 @@ +import express from "express"; +import * as crypto from "crypto"; +import { LRUCache } from "lru-cache"; +import { UnauthorizedError, UnprocessableEntityError } from "./errors"; + +/** + * Native-app sign-in handoff. + * + * The normal flow leaves the signed-in session as a cookie in whichever + * browser ran the OIDC dance. A native app can't read cookies out of the + * system browser, so today it has to embed a web view and scrape the cookie — + * which also rules out passkeys (WebKit doesn't expose WebAuthn to embedded + * views for third-party origins) and is the kind of embedded OAuth Google + * discourages. + * + * With this handoff a native app opens the regular login page in the system + * browser with a `returnTo` on one of its registered URL schemes + * (NATIVE_APP_SCHEMES). After sign-in, /oidc/callback redirects there with a + * short-lived, single-use `code`, and the app exchanges it at + * POST /auth/exchange for an ordinary session cookie. + */ + +const NATIVE_APP_SCHEMES = new Set( + (process.env.NATIVE_APP_SCHEMES ?? "") + .split(",") + .map(scheme => scheme.trim().toLowerCase().replace(/:$/, "")) + .filter(Boolean), +); + +export const NATIVE_AUTH_CODE_TTL_MS = 5 * 60 * 1000; + +export interface PendingNativeSession { + id_token: string; +} + +// In-memory like `activeConnections`: codes are only meaningful for the +// instance that issued them, and they live for five minutes at most. +const pendingSessions = new LRUCache({ + max: 10_000, + ttl: NATIVE_AUTH_CODE_TTL_MS, +}); + +/** True when `returnTo` is a URL on one of the registered native-app schemes. */ +export function isNativeReturnTo(returnTo: unknown): returnTo is string { + if (typeof returnTo !== "string" || NATIVE_APP_SCHEMES.size === 0) return false; + let url: URL; + try { + url = new URL(returnTo); + } catch { + return false; + } + const scheme = url.protocol.replace(/:$/, "").toLowerCase(); + if (scheme === "http" || scheme === "https") return false; + return NATIVE_APP_SCHEMES.has(scheme); +} + +export function issueNativeAuthCode(session: PendingNativeSession): string { + const code = crypto.randomBytes(32).toString("base64url"); + pendingSessions.set(code, session); + return code; +} + +/** Returns the session for a code and invalidates the code. */ +export function redeemNativeAuthCode(code: string): PendingNativeSession | undefined { + const session = pendingSessions.get(code); + if (session) pendingSessions.delete(code); + return session; +} + +export function nativeRedirectUrl(returnTo: string, code: string): string { + const url = new URL(returnTo); + url.searchParams.set("code", code); + return url.toString(); +} + +/** POST /auth/exchange { code } → session cookie for the native app. */ +export const Exchange = async (req: express.Request, res: express.Response) => { + const { code } = (req.body ?? {}) as { code?: unknown }; + if (typeof code !== "string" || !code) { + throw new UnprocessableEntityError("Missing code in body"); + } + + const pending = redeemNativeAuthCode(code); + if (!pending) + throw new UnauthorizedError("Invalid or expired code", "invalid_auth_code"); + + req.session = { id_token: pending.id_token }; + return res.json({ ok: true }); +}; diff --git a/src/oidc.ts b/src/oidc.ts index 7908e76..2747df0 100644 --- a/src/oidc.ts +++ b/src/oidc.ts @@ -3,6 +3,7 @@ import express from "express"; import { prisma } from "./db"; import { BadRequestError, UnauthorizedError } from "./errors"; import { isIdentityAllowed } from "./auth"; +import { isNativeReturnTo, issueNativeAuthCode, nativeRedirectUrl } from "./native-auth"; import * as crypto from "crypto"; const API_HOSTNAME = process.env.API_HOSTNAME; @@ -165,5 +166,13 @@ export const Callback = async (req: express.Request, res: express.Response) => { url.searchParams.append("clientId", process.env.GOOGLE_CLIENT_ID); return res.redirect(url.toString()); } + + if (isNativeReturnTo(returnTo)) { + // Hand the session to the native app through a one-time code, and leave + // no session behind in the browser that ran the sign-in. + const code = issueNativeAuthCode({ id_token: tokenSet.id_token }); + req.session = null; + return res.redirect(nativeRedirectUrl(returnTo, code)); + } return res.redirect(returnTo); }; diff --git a/test/native-auth.test.ts b/test/native-auth.test.ts new file mode 100644 index 0000000..2f9408b --- /dev/null +++ b/test/native-auth.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, beforeAll, vi } from "vitest"; +import { Request, Response } from "express"; + +type NativeAuth = typeof import("../src/native-auth"); + +// The scheme allowlist is read once at import, like ALLOWED_IDENTITIES. +async function loadWithSchemes(schemes: string | undefined): Promise { + vi.resetModules(); + if (schemes === undefined) delete process.env.NATIVE_APP_SCHEMES; + else process.env.NATIVE_APP_SCHEMES = schemes; + return import("../src/native-auth"); +} + +function mockResponse() { + return { json: vi.fn(), redirect: vi.fn() } as unknown as Response & { + json: ReturnType; + }; +} + +describe("isNativeReturnTo", () => { + let nativeAuth: NativeAuth; + beforeAll(async () => { + nativeAuth = await loadWithSchemes("jetpilot, MyApp:"); + }); + + it("accepts URLs on a registered scheme, case-insensitively", () => { + expect(nativeAuth.isNativeReturnTo("jetpilot://auth")).toBe(true); + expect(nativeAuth.isNativeReturnTo("JetPilot://auth/done?x=1")).toBe(true); + expect(nativeAuth.isNativeReturnTo("myapp://signed-in")).toBe(true); + }); + + it("rejects web URLs, unregistered schemes, and garbage", () => { + expect(nativeAuth.isNativeReturnTo("https://app.jetkvm.com/devices")).toBe(false); + expect(nativeAuth.isNativeReturnTo("http://jetpilot/auth")).toBe(false); + expect(nativeAuth.isNativeReturnTo("otherapp://auth")).toBe(false); + expect(nativeAuth.isNativeReturnTo("javascript:alert(1)")).toBe(false); + expect(nativeAuth.isNativeReturnTo("not a url")).toBe(false); + expect(nativeAuth.isNativeReturnTo(undefined)).toBe(false); + expect(nativeAuth.isNativeReturnTo(null)).toBe(false); + }); + + it("is disabled when NATIVE_APP_SCHEMES is unset", async () => { + const disabled = await loadWithSchemes(undefined); + expect(disabled.isNativeReturnTo("jetpilot://auth")).toBe(false); + }); +}); + +describe("native auth codes", () => { + let nativeAuth: NativeAuth; + beforeAll(async () => { + nativeAuth = await loadWithSchemes("jetpilot"); + }); + + it("are single-use", () => { + const code = nativeAuth.issueNativeAuthCode({ id_token: "token-1" }); + expect(code.length).toBeGreaterThanOrEqual(43); + expect(nativeAuth.redeemNativeAuthCode(code)).toEqual({ id_token: "token-1" }); + expect(nativeAuth.redeemNativeAuthCode(code)).toBeUndefined(); + expect(nativeAuth.redeemNativeAuthCode("nope")).toBeUndefined(); + }); + + it("are appended to the app's returnTo as `code`", () => { + const url = new URL( + nativeAuth.nativeRedirectUrl("jetpilot://auth?from=cloud", "abc"), + ); + expect(url.protocol).toBe("jetpilot:"); + expect(url.searchParams.get("from")).toBe("cloud"); + expect(url.searchParams.get("code")).toBe("abc"); + }); +}); + +describe("POST /auth/exchange", () => { + let nativeAuth: NativeAuth; + beforeAll(async () => { + nativeAuth = await loadWithSchemes("jetpilot"); + }); + + it("requires a code", async () => { + const req = { body: {}, session: null } as unknown as Request; + // Compared by status: vi.resetModules() gives the error classes a new identity. + await expect(nativeAuth.Exchange(req, mockResponse())).rejects.toMatchObject({ + status: 422, + }); + }); + + it("rejects unknown or already-used codes", async () => { + const code = nativeAuth.issueNativeAuthCode({ id_token: "token-2" }); + nativeAuth.redeemNativeAuthCode(code); + const req = { body: { code }, session: null } as unknown as Request; + await expect(nativeAuth.Exchange(req, mockResponse())).rejects.toMatchObject({ + status: 401, + code: "invalid_auth_code", + }); + }); + + it("establishes a session for a valid code", async () => { + const code = nativeAuth.issueNativeAuthCode({ id_token: "token-3" }); + const req = { body: { code }, session: null } as unknown as Request; + const res = mockResponse(); + await nativeAuth.Exchange(req, res); + expect(req.session).toEqual({ id_token: "token-3" }); + expect(res.json).toHaveBeenCalledWith({ ok: true }); + }); +});