Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=<scheme>://..., 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=

Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -47,6 +48,7 @@ declare global {
ICE_SERVERS: string;

ALLOWED_IDENTITIES?: string;
NATIVE_APP_SCHEMES?: string;
}
}
}
Expand Down Expand Up @@ -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
Expand Down
89 changes: 89 additions & 0 deletions src/native-auth.ts
Original file line number Diff line number Diff line change
@@ -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<string, PendingNativeSession>({
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 });
};
9 changes: 9 additions & 0 deletions src/oidc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
};
104 changes: 104 additions & 0 deletions test/native-auth.test.ts
Original file line number Diff line number Diff line change
@@ -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<NativeAuth> {
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<typeof vi.fn>;
};
}

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 });
});
});