Skip to content
Merged
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
1 change: 1 addition & 0 deletions .agents/skills/use-pake/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ After build, confirm:
| `--multi-instance` | false | Allow multiple app instances |
| `--multi-window` | false | Multiple windows in one instance |
| `--new-window` | false | Allow popup windows (needed for OAuth flows) |
| `--safe-domain <domains>` | none | Keep trusted SSO/workspace domains in-app |
| `--incognito` | false | Private browsing mode |
| `--dark-mode` | false | Force macOS dark mode |
| `--zoom <number>` | 100 | Initial zoom level (50-200) |
Expand Down
13 changes: 13 additions & 0 deletions docs/cli-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,19 @@ Set a regex pattern to determine which URLs should be considered internal (opene
--internal-url-regex "^https://(app|api)\\.example\\.com"
```

#### [safe-domain]

A simpler way to keep trusted domains and their subdomains inside the app. This is useful for workspace callbacks and enterprise SSO flows, for example Slack plus Okta. Pake compiles this list into `internal_url_regex`; if `--internal-url-regex` is also set, the explicit regex wins.

`--safe-domain` matches URL hosts only, not arbitrary path or query text.

```shell
--safe-domain <domains>

# Keep Slack and Okta auth redirects inside the app
--safe-domain slack.com,okta.com
```

#### [multi-arch]

Package the application to support both Intel and M1 chips, exclusively for macOS. Default is `false`.
Expand Down
13 changes: 13 additions & 0 deletions docs/cli-usage_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,19 @@ pake https://github.com --name GitHub
--internal-url-regex "^https://(app|api)\\.example\\.com"
```

#### [safe-domain]

更简单地把可信域名及其子域名保留在应用内打开。适合工作区回调和企业 SSO 登录流程,例如 Slack 加 Okta。Pake 会把这个列表编译成 `internal_url_regex`;如果同时设置了 `--internal-url-regex`,则以显式正则为准。

`--safe-domain` 只匹配 URL 的 host,不会因为路径或查询参数里出现域名就误判为内部链接。

```shell
--safe-domain <domains>

# 将 Slack 和 Okta 的认证跳转保留在应用内
--safe-domain slack.com,okta.com
```

#### [multi-arch]

设置打包结果同时支持 Intel 和 M1 芯片,仅适用于 macOS,默认为 `false`。
Expand Down
32 changes: 20 additions & 12 deletions src-tauri/src/inject/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,6 @@ function matchesAuthUrl(url, baseUrl = window.location.href) {
/facebook\.com\/.*\/dialog/,
/twitter\.com\/oauth/,
/appleid\.apple\.com/,
// Enterprise SSO providers and SAML/ADFS endpoints
/\.okta\.com/,
/\.onelogin\.com/,
/\/saml\//,
/\/sso\//,
/adfs\/ls/,
/\/oauth\//,
/\/auth\//,
/\/authorize/,
Expand All @@ -33,12 +27,26 @@ function matchesAuthUrl(url, baseUrl = window.location.href) {
/\/o\/oauth2/,
];

const isMatch = oauthPatterns.some(
(pattern) =>
pattern.test(hostname) ||
pattern.test(pathname) ||
pattern.test(fullUrl),
);
// Enterprise SSO. Match identity providers on the host, and SAML/SSO/ADFS on
// the pathname with endpoint-shaped patterns only, so ordinary pages such as
// /settings/sso/providers (or a query string carrying an SSO URL) are not
// misread as authentication.
const enterpriseHostPatterns = [/(^|\.)okta\.com$/, /(^|\.)onelogin\.com$/];
const enterprisePathPatterns = [
/\/saml2?\/(sso|acs|login|metadata|consume|redirect|callback|continue)/,
/\/sso\/(saml|oidc|oauth|login|authorize|redirect|callback|acs|start|continue|metadata)/,
/\/adfs\/ls\b/,
];

const isMatch =
oauthPatterns.some(
(pattern) =>
pattern.test(hostname) ||
pattern.test(pathname) ||
pattern.test(fullUrl),
) ||
enterpriseHostPatterns.some((pattern) => pattern.test(hostname)) ||
enterprisePathPatterns.some((pattern) => pattern.test(pathname));

if (isMatch) {
console.log("[Pake] OAuth URL detected:", url);
Expand Down
21 changes: 21 additions & 0 deletions tests/unit/auth-sso-patterns.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,27 @@ describe("auth SSO patterns", () => {
expect(isAuthLink("https://example.com/reports/q3")).toBe(false);
});

it("does not flag ordinary pages that merely contain sso or saml in the path", () => {
expect(isAuthLink("https://app.example.com/settings/sso/providers")).toBe(
false,
);
expect(isAuthLink("https://app.example.com/docs/saml/overview")).toBe(
false,
);
});

it("does not flag a query string that carries an SSO URL", () => {
expect(
isAuthLink(
"https://app.example.com/?next=https://idp.example.com/sso/saml",
),
).toBe(false);
});

it("does not flag look-alike provider suffix hosts", () => {
expect(isAuthLink("https://okta.com.evil.test/app")).toBe(false);
});

it("treats known auth window names as popups", () => {
expect(isAuthPopup("https://example.com/dashboard", "oauth2")).toBe(true);
});
Expand Down
125 changes: 116 additions & 9 deletions tests/unit/event-link-guard.test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import fs from "fs";
import path from "path";
import { runInNewContext } from "node:vm";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";

function loadEventHelpers({
withTauri = false,
Expand All @@ -17,6 +17,36 @@ function loadEventHelpers({
invokeCalls.push([command, payload]);
return Promise.resolve();
};
const eventListeners = {};
const elementsById = new Map();
const registerListener = (type, handler, options) => {
eventListeners[type] = eventListeners[type] || [];
eventListeners[type].push({ handler, options });
};
const createElement = (tagName = "div") => ({
tagName: tagName.toUpperCase(),
style: {},
children: [],
addEventListener: () => {},
appendChild(child) {
this.children.push(child);
if (child.id) elementsById.set(child.id, child);
},
removeChild(child) {
this.children = this.children.filter((item) => item !== child);
if (child.id) elementsById.delete(child.id);
},
click: () => {},
set id(value) {
this._id = value;
elementsById.set(value, this);
},
get id() {
return this._id;
},
});
const body = createElement("body");
body.scrollHeight = 0;

const context = {
console,
Expand Down Expand Up @@ -45,26 +75,67 @@ function loadEventHelpers({
getItem: () => null,
setItem: () => {},
},
addEventListener: () => {},
addEventListener: registerListener,
dispatchEvent: () => {},
open: () => ({}),
isAuthLink: () => false,
isAuthPopup: () => false,
pakeConfig: {},
},
document: {
addEventListener: () => {},
addEventListener: registerListener,
createElement,
getElementById: (id) => elementsById.get(id) || null,
getElementsByTagName: () => [{ style: {} }],
body: {
style: {},
scrollHeight: 0,
},
body,
execCommand: () => {},
},
};
context.window.navigator = context.navigator;
if (withTauri) {
context.window.__TAURI__ = { core: { invoke } };
context.window.__TAURI__ = {
core: { invoke },
window: {
getCurrentWindow: () => ({
startDragging: () => {},
isFullscreen: () => Promise.resolve(false),
setFullscreen: () => {},
}),
},
};
}

runInNewContext(source, context);
return { ...context, invokeCalls };
return { ...context, eventListeners, invokeCalls };
}

function runDomReady(context) {
context.eventListeners.DOMContentLoaded[0].handler();
}

function getClickGuard(context) {
return context.eventListeners.click.find(
({ handler }) => handler.name === "detectAnchorElementClick",
).handler;
}

function makeAnchor(href, target = "_blank") {
return {
href,
target,
download: "",
getAttribute: (name) => (name === "href" ? href : ""),
};
}

function makeClickEvent(anchor) {
return {
target: {
closest: () => anchor,
},
preventDefault: vi.fn(),
stopImmediatePropagation: vi.fn(),
};
}

describe("event link guard", () => {
Expand Down Expand Up @@ -139,6 +210,42 @@ describe("event link guard", () => {
expect(result).toBe(popup);
});

it("navigates target blank auth links in-place when new-window is disabled", () => {
const context = loadEventHelpers({ withTauri: true });
context.window.pakeConfig = { new_window: false };
context.window.isAuthLink = (url) => url.includes("okta.com");
runDomReady(context);

const event = makeClickEvent(
makeAnchor("https://mycompany.okta.com/sso", "_blank"),
);
getClickGuard(context)(event);

expect(event.preventDefault).toHaveBeenCalled();
expect(event.stopImmediatePropagation).toHaveBeenCalled();
expect(context.window.location.href).toBe("https://mycompany.okta.com/sso");
});

it("navigates target blank internal links in-place when new-window is disabled", () => {
const context = loadEventHelpers({ withTauri: true });
context.window.pakeConfig = {
new_window: false,
internal_url_regex: "^https://app\\.example\\.com",
};
runDomReady(context);

const event = makeClickEvent(
makeAnchor("https://app.example.com/callback", "_blank"),
);
getClickGuard(context)(event);

expect(event.preventDefault).toHaveBeenCalled();
expect(event.stopImmediatePropagation).toHaveBeenCalled();
expect(context.window.location.href).toBe(
"https://app.example.com/callback",
);
});

it("bridges Web Badging API calls to explicit badge commands", async () => {
const { navigator, invokeCalls } = loadEventHelpers({ withTauri: true });

Expand Down
Loading