mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-12 23:31:02 +00:00
123 lines
4.5 KiB
TypeScript
123 lines
4.5 KiB
TypeScript
/**
|
|
* Self-hosters often put a sign-in page of their own in front of TREK: an
|
|
* identity-aware proxy such as Cloudflare Access, Pangolin, Authentik,
|
|
* Authelia or oauth2-proxy, or a reverse proxy's password prompt. The app's
|
|
* server check then gets that page instead of TREK's answer and used to call
|
|
* the server "not TREK". This reads which gate it met, so the address screen
|
|
* can say so, and offers its sign-in inside the app where that can work.
|
|
*/
|
|
|
|
export type AuthGateKind = 'cloudflare' | 'pangolin' | 'authentik' | 'authelia' | 'oauth2proxy' | 'basic' | 'unknown';
|
|
|
|
export interface AuthGate {
|
|
kind: AuthGateKind;
|
|
/**
|
|
* The host the gate's sign-in page lives on, when the check was sent there.
|
|
* Only these hosts are let into the app's web view while signing in; every
|
|
* other foreign page still opens in the system browser.
|
|
*/
|
|
loginHost: string | null;
|
|
}
|
|
|
|
/** The product names the address screen shows; not translated. */
|
|
export const AUTH_GATE_NAMES: Record<Exclude<AuthGateKind, 'unknown' | 'basic'>, string> = {
|
|
cloudflare: 'Cloudflare Access',
|
|
pangolin: 'Pangolin',
|
|
authentik: 'authentik',
|
|
authelia: 'Authelia',
|
|
oauth2proxy: 'OAuth2 Proxy',
|
|
};
|
|
|
|
export interface GateResponse {
|
|
status: number;
|
|
data: unknown;
|
|
/** The URL after redirects. */
|
|
url: string;
|
|
headers?: Record<string, string>;
|
|
}
|
|
|
|
function header(headers: Record<string, string> | undefined, name: string): string {
|
|
if (!headers) return '';
|
|
const wanted = name.toLowerCase();
|
|
for (const [key, value] of Object.entries(headers)) if (key.toLowerCase() === wanted) return String(value);
|
|
return '';
|
|
}
|
|
|
|
function hostOf(url: string): string | null {
|
|
try {
|
|
return new URL(url).host.toLowerCase();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** A page that asks for a sign-in: a password field, or the words for it. */
|
|
function looksLikeSignIn(html: string): boolean {
|
|
return /type=["']?password/i.test(html) || /\b(sign|log)[\s-]?in\b/i.test(html);
|
|
}
|
|
|
|
/** What the check got back, read once for the rules below. */
|
|
interface Seen {
|
|
host: string | null;
|
|
path: string;
|
|
html: string;
|
|
status: number;
|
|
headers: Record<string, string> | undefined;
|
|
}
|
|
|
|
/** The named gates, each by what gives it away; the first that matches wins. */
|
|
const NAMED_GATES: Array<[Exclude<AuthGateKind, 'basic' | 'unknown'>, (seen: Seen) => boolean]> = [
|
|
[
|
|
'cloudflare',
|
|
(seen) => !!seen.host?.endsWith('.cloudflareaccess.com') || !!header(seen.headers, 'cf-access-domain'),
|
|
],
|
|
['authentik', (seen) => seen.path.includes('/outpost.goauthentik.io/') || /authentik/i.test(seen.html)],
|
|
['authelia', (seen) => /authelia/i.test(seen.html)],
|
|
['oauth2proxy', (seen) => seen.path.startsWith('/oauth2/') || /oauth2[\s-]?proxy/i.test(seen.html)],
|
|
['pangolin', (seen) => seen.path.startsWith('/auth/resource/') || /pangolin/i.test(seen.html)],
|
|
];
|
|
|
|
function pathOf(url: string): string {
|
|
try {
|
|
return new URL(url).pathname;
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
/** A gate that gives no name away: a password prompt, or any other page that asks for a sign-in. */
|
|
function unnamedGate(seen: Seen, loginHost: string | null): AuthGate | null {
|
|
if (seen.status === 401 && /^basic\b/i.test(header(seen.headers, 'www-authenticate'))) {
|
|
return { kind: 'basic', loginHost: null };
|
|
}
|
|
// Only a page that reads as a sign-in counts: a server that is simply
|
|
// something other than TREK answers with a page of its own too.
|
|
if (!seen.html || !looksLikeSignIn(seen.html)) return null;
|
|
return loginHost || [200, 401, 403].includes(seen.status) ? { kind: 'unknown', loginHost } : null;
|
|
}
|
|
|
|
/**
|
|
* The gate in front of `requested`, judged from what came back instead of
|
|
* TREK's answer, or null when nothing points to one: then the address simply
|
|
* is not a TREK server.
|
|
*/
|
|
export function detectAuthGate(requested: string, response: GateResponse): AuthGate | null {
|
|
const finalUrl = response.url || requested;
|
|
const host = hostOf(finalUrl);
|
|
const loginHost = host !== null && host !== hostOf(requested) ? host : null;
|
|
const seen: Seen = {
|
|
host,
|
|
path: pathOf(finalUrl),
|
|
html: typeof response.data === 'string' ? response.data : '',
|
|
status: response.status,
|
|
headers: response.headers,
|
|
};
|
|
const named = NAMED_GATES.find(([, matches]) => matches(seen));
|
|
return named ? { kind: named[0], loginHost } : unnamedGate(seen, loginHost);
|
|
}
|
|
|
|
/** Whether the app can show this gate's sign-in itself: a password prompt it cannot. */
|
|
export function canSignInInApp(gate: AuthGate): boolean {
|
|
return gate.kind !== 'basic';
|
|
}
|