feat: cleanup oidc logic and surface errors better

This commit is contained in:
Aarnav Tale
2025-12-04 00:44:31 -05:00
parent d363ed5486
commit ba61656fb0
14 changed files with 624 additions and 628 deletions
+123
View File
@@ -0,0 +1,123 @@
import { AlertCircle } from 'lucide-react';
import Card from '~/components/Card';
import Code from '~/components/Code';
import Link from '~/components/Link';
import { OidcConnectorError } from '~/server/web/oidc-connector';
export function OidcConfigErrorNotice({
errors,
}: {
errors: OidcConnectorError[];
}) {
return (
<Card className="max-w-md m-4 sm:m-0 mb-4 sm:mb-4 border border-red-500">
<div className="flex items-center justify-between gap-4">
<Card.Title className="text-red-500">Authentication Error</Card.Title>
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
</div>
<Card.Text className="text-sm">
The OpenID Connect (OIDC) Single Sign-On (SSO) configuration has issues:{' '}
<ul className="list-disc list-inside mt-2 mb-1">
{mapOidcErrorsToMessages(errors).map((code) => (
<li key={code.key}>{code.node}</li>
))}
</ul>{' '}
<Link
name="Headplane OIDC Issues"
to="https://headplane.net/configuration/sso#help"
>
Learn more
</Link>
</Card.Text>
</Card>
);
}
function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
const messages: {
key: string;
node: React.ReactNode;
}[] = [];
for (const error of errors) {
switch (error) {
case 'INVALID_API_KEY':
messages.push({
key: error,
node: (
<Card.Text className="inline">
The provided API key for OIDC authentication is invalid. Ensure
that <Code>oidc.headscale_api_key</Code> is a valid API key.
</Card.Text>
),
});
break;
case 'MISSING_AUTHORIZATION_ENDPOINT':
messages.push({
key: error,
node: (
<Card.Text className="inline">
The OIDC provided does not have a configured{' '}
<Code>authorization_endpoint</Code>. Ensure discovery URL or
manual configuration is correct.
</Card.Text>
),
});
break;
case 'MISSING_TOKEN_ENDPOINT':
messages.push({
key: error,
node: (
<Card.Text className="inline">
The OIDC provided does not have a configured{' '}
<Code>token_endpoint</Code>. Ensure discovery URL or manual
configuration is correct.
</Card.Text>
),
});
break;
case 'MISSING_USERINFO_ENDPOINT':
messages.push({
key: error,
node: (
<Card.Text className="inline">
The OIDC provided does not have a configured{' '}
<Code>user_endpoint</Code>. Ensure discovery URL or manual
configuration is correct.
</Card.Text>
),
});
break;
case 'MISSING_REQUIRED_CLAIMS':
messages.push({
key: error,
node: (
<Card.Text className="inline">
The OIDC provider does not support the <Code>sub</Code> claim,
which is required for authentication. Your OIDC provider may be
misconfigured.
</Card.Text>
),
});
break;
case 'UNKNOWN_ERROR':
messages.push({
key: error,
node: (
<Card.Text className="inline">
An unknown error occurred during OIDC configuration. Please check
the Headplane logs for more information.
</Card.Text>
),
});
break;
}
}
return messages;
}
+63
View File
@@ -0,0 +1,63 @@
import { AlertCircle } from 'lucide-react';
import Card from '~/components/Card';
import Code from '~/components/Code';
export function OidcErrorNotice({ code }: { code: string }) {
return (
<Card className="max-w-md m-4 sm:m-0 mb-4 sm:mb-4 border border-red-500">
<div className="flex items-center justify-between gap-4">
<Card.Title className="text-red-500">Configuration Issue(s)</Card.Title>
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
</div>
{getErrorMessage(code)}
</Card>
);
}
function getErrorMessage(code: string) {
switch (code) {
case 'error_no_query':
return (
<Card.Text>
The SSO provider did not correctly redirect back to Headplane with the
required parameters. Please ensure your SSO provider is configured
correctly.
</Card.Text>
);
case 'error_no_session':
case 'error_invalid_session':
return (
<Card.Text>
Unable to complete SSO login due to missing or invalid session data.
Ensure that your Headplane cookie configuration is correct and that
your browser is accepting cookies.
</Card.Text>
);
case 'error_no_sub':
return (
<Card.Text>
The SSO provider did not return a valid user identifier. Please ensure
your SSO provider is correctly configured to provide the{' '}
<Code>sub</Code> claim.
</Card.Text>
);
case 'error_auth_failed':
return (
<Card.Text>
Authentication with the SSO provider failed. Please tray again later.
Headplane logs may provide more information.
</Card.Text>
);
default:
return (
<Card.Text>
An unknown error occurred during OIDC authentication. Please try again
later.
</Card.Text>
);
}
}
+41 -49
View File
@@ -12,10 +12,13 @@ import Card from '~/components/Card';
import Code from '~/components/Code'; import Code from '~/components/Code';
import Input from '~/components/Input'; import Input from '~/components/Input';
import Link from '~/components/Link'; import Link from '~/components/Link';
import { OidcConnectorError } from '~/server/web/oidc-connector';
import { useLiveData } from '~/utils/live-data'; import { useLiveData } from '~/utils/live-data';
import type { Route } from './+types/page'; import type { Route } from './+types/page';
import { loginAction } from './action'; import { loginAction } from './action';
import { OidcConfigErrorNotice } from './config-error';
import Logout from './logout'; import Logout from './logout';
import { OidcErrorNotice } from './oidc-error';
export async function loader({ request, context }: Route.LoaderArgs) { export async function loader({ request, context }: Route.LoaderArgs) {
try { try {
@@ -26,28 +29,21 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const qp = new URL(request.url).searchParams; const qp = new URL(request.url).searchParams;
const urlState = qp.get('s') ?? undefined; const urlState = qp.get('s') ?? undefined;
// OIDC config cannot be undefined if an OIDC client is set // MARK: This works because the OIDC connector will always return false
// Also check if we are in a logout state and skip redirect if we are // for `isExclusive` if the OIDC config isn't usable.
const ssoOnly = context.config.oidc?.disable_api_key_login; if (context.oidcConnector?.isExclusive && urlState !== 'logout') {
if (urlState !== 'logout' && ssoOnly) {
// This shouldn't be possible, but still a safe sanity check
if (!context.oidc || typeof context.oidc === 'string') {
throw data(
'`oidc.disable_api_key_login` was set without a valid OIDC configuration',
{
status: 400,
},
);
}
return redirect('/oidc/start'); return redirect('/oidc/start');
} }
const isOidcConnectorEnabled = context.oidcConnector?.isValid;
const oidcErrorCodes = !isOidcConnectorEnabled
? context.oidcConnector!.errors
: [];
return { return {
isCookieSecureEnabled: context.config.server.cookie_secure, isCookieSecureEnabled: context.config.server.cookie_secure,
isOidcEnabled: context.oidc !== undefined, isOidcConnectorEnabled,
oidcErrorMessage: oidcErrorCodes,
typeof context.oidc === 'string' ? context.oidc : undefined,
urlState, urlState,
}; };
} }
@@ -55,8 +51,13 @@ export async function loader({ request, context }: Route.LoaderArgs) {
export const action = loginAction; export const action = loginAction;
export default function Page({ loaderData, actionData }: Route.ComponentProps) { export default function Page({ loaderData, actionData }: Route.ComponentProps) {
const { isOidcEnabled, isCookieSecureEnabled, oidcErrorMessage, urlState } = const {
loaderData; isCookieSecureEnabled,
isOidcConnectorEnabled,
oidcErrorCodes,
urlState,
} = loaderData;
const [showCookieWarning, setShowCookieWarning] = useState(false); const [showCookieWarning, setShowCookieWarning] = useState(false);
const [params] = useSearchParams(); const [params] = useSearchParams();
const { pause } = useLiveData(); const { pause } = useLiveData();
@@ -95,40 +96,31 @@ export default function Page({ loaderData, actionData }: Route.ComponentProps) {
return ( return (
<div className="flex w-screen h-screen items-center justify-center"> <div className="flex w-screen h-screen items-center justify-center">
<div> <div>
{showCookieWarning || oidcErrorMessage ? ( {urlState?.startsWith('error_') ? (
<OidcErrorNotice code={urlState} />
) : oidcErrorCodes.length > 0 ? (
<OidcConfigErrorNotice errors={oidcErrorCodes} />
) : showCookieWarning ? (
<Card className="max-w-md m-4 sm:m-0 mb-4 sm:mb-4 border border-red-500"> <Card className="max-w-md m-4 sm:m-0 mb-4 sm:mb-4 border border-red-500">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<Card.Title className="text-red-500"> <Card.Title className="text-red-500">
Configuration Issue(s) Configuration Issue
</Card.Title> </Card.Title>
<AlertCircle className="w-6 h-6 mb-2 text-red-500" /> <AlertCircle className="w-6 h-6 mb-2 text-red-500" />
</div> </div>
<div className="flex flex-col gap-2"> {showCookieWarning ? (
{showCookieWarning ? ( <Card.Text className="text-sm">
<Card.Text className="text-sm text-red-600 dark:text-red-400"> Headplane is configured to use secure cookies, but this site is
Headplane is configured to use secure cookies, but this site being served over an insecure connection and login will not work
is being served over an insecure connection and login will not correctly.{' '}
work correctly.{' '} <Link
<Link name="Headplane Common Issues"
name="Headplane Common Issues" to="https://headplane.net/configuration/common-issues#issue-logging-in-does-not-do-anything"
to="https://headplane.net/configuration/common-issues#issue-logging-in-does-not-do-anything" >
> Learn more.
Learn more. </Link>
</Link> </Card.Text>
</Card.Text> ) : undefined}
) : undefined}
{oidcErrorMessage ? (
<Card.Text className="text-sm text-red-600 dark:text-red-400">
{oidcErrorMessage}{' '}
<Link
name="Headplane OIDC Issues"
to="https://headplane.net/configuration/sso#help"
>
Learn more.
</Link>
</Card.Text>
) : undefined}
</div>
</Card> </Card>
) : undefined} ) : undefined}
<Card className="max-w-md m-4 sm:m-0"> <Card className="max-w-md m-4 sm:m-0">
@@ -157,11 +149,11 @@ export default function Page({ loaderData, actionData }: Route.ComponentProps) {
Sign In Sign In
</Button> </Button>
</Form> </Form>
{isOidcEnabled ? ( {isOidcConnectorEnabled ? (
<RemixLink to="/oidc/start"> <RemixLink to="/oidc/start">
<Button <Button
className="w-full mt-2" className="w-full mt-2"
isDisabled={oidcErrorMessage !== undefined} isDisabled={oidcErrorCodes.length > 0}
variant="light" variant="light"
> >
Single Sign-On Single Sign-On
+96 -78
View File
@@ -1,75 +1,99 @@
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { count, eq } from 'drizzle-orm'; import { count, eq } from 'drizzle-orm';
import { createCookie, type LoaderFunctionArgs, redirect } from 'react-router'; import * as oidc from 'openid-client';
import {
createCookie,
data,
type LoaderFunctionArgs,
redirect,
} from 'react-router';
import { ulid } from 'ulidx'; import { ulid } from 'ulidx';
import type { LoadContext } from '~/server'; import type { LoadContext } from '~/server';
import { HeadplaneConfig } from '~/server/config/config-schema';
import { users } from '~/server/db/schema'; import { users } from '~/server/db/schema';
import { Roles } from '~/server/web/roles'; import { Roles } from '~/server/web/roles';
import { FlowUser, finishAuthFlow, formatError } from '~/utils/oidc'; import log from '~/utils/log';
import { send } from '~/utils/res'; import type { OidcCookieState } from './oidc-start';
interface OidcFlowSession {
state: string;
nonce: string;
code_verifier: string;
redirect_uri: string;
}
export async function loader({ export async function loader({
request, request,
context, context,
}: LoaderFunctionArgs<LoadContext>) { }: LoaderFunctionArgs<LoadContext>) {
if (!context.oidc || typeof context.oidc === 'string') { if (!context.oidcConnector?.isValid) {
throw new Error('OIDC is not enabled'); throw data('OIDC is not enabled or misconfigured', { status: 501 });
} }
// Check if we have 0 query parameters
const url = new URL(request.url); const url = new URL(request.url);
if (url.searchParams.toString().length === 0) { if (url.searchParams.toString().length === 0) {
return redirect('/login'); return redirect('/login?s=error_no_query');
} }
const cookie = createCookie('__oidc_auth_flow', { const cookie = createCookie('__oidc_auth_flow', {
httpOnly: true, httpOnly: true,
maxAge: 300, // 5 minutes maxAge: 300,
secure: context.config.server.cookie_secure,
domain: context.config.server.cookie_domain,
}); });
const data: OidcFlowSession | null = await cookie.parse( const oidcCookieState: OidcCookieState | null = await cookie.parse(
request.headers.get('Cookie'), request.headers.get('Cookie'),
); );
if (data === null) { if (oidcCookieState == null || typeof oidcCookieState !== 'object') {
console.warn('OIDC flow session not found'); log.warn('auth', 'Called OIDC callback without session cookie');
return redirect('/login'); return redirect('/login?s=error_no_session');
} }
const { code_verifier, state, nonce, redirect_uri } = data; const { state, nonce } = oidcCookieState;
if (!code_verifier || !state || !nonce || !redirect_uri) { if (!state || !nonce) {
return send({ error: 'Missing OIDC state' }, { status: 400 }); log.warn('auth', 'OIDC session cookie is missing required fields');
return redirect('/login?s=error_invalid_session');
} }
// Reconstruct the redirect URI using the query parameters
// and the one we saved in the session
const flowRedirectUri = new URL(redirect_uri);
flowRedirectUri.search = url.search;
const flowOptions = {
redirect_uri: flowRedirectUri.toString(),
code_verifier,
state,
nonce: nonce === '<none>' ? undefined : nonce,
};
try { try {
let user = await finishAuthFlow(context.oidc, flowOptions); const tokens = await oidc.authorizationCodeGrant(
user = { context.oidcConnector.client,
...user, request,
picture: setOidcPictureForSource( {
user, expectedState: state,
context.config.oidc?.profile_picture_source ?? 'oidc', expectedNonce: nonce,
), },
}; );
const claims = tokens.claims();
if (claims?.sub == null) {
log.warn('auth', 'No subject found in OIDC claims');
return redirect('/login?s=error_no_sub');
}
const userInfo = await oidc.fetchUserInfo(
context.oidcConnector.client,
tokens.access_token,
claims.sub,
);
// We have defaults that closely follow what Headscale uses, maybe we
// can make it configurable in the future, but for now we only need the
// `sub` claim.
const username =
userInfo.preferred_username ?? userInfo.email?.split('@')[0] ?? 'user';
const name =
userInfo.name ??
(userInfo.given_name && userInfo.family_name
? `${userInfo.given_name} ${userInfo.family_name}`
: (userInfo.preferred_username ?? 'SSO User'));
const picture =
context.config.oidc?.profile_picture_source === 'gravatar'
? (() => {
if (!userInfo.email) {
return undefined;
}
const emailHash = userInfo.email.trim().toLowerCase();
const hash = createHash('sha256').update(emailHash).digest('hex');
return `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon&r=x`;
})()
: userInfo.picture;
const [{ count: userCount }] = await context.db const [{ count: userCount }] = await context.db
.select({ count: count() }) .select({ count: count() })
@@ -80,52 +104,46 @@ export async function loader({
.insert(users) .insert(users)
.values({ .values({
id: ulid(), id: ulid(),
sub: user.subject, sub: claims.sub,
caps: userCount === 0 ? Roles.owner : Roles.member, caps: userCount === 0 ? Roles.owner : Roles.member,
}) })
.onConflictDoNothing(); .onConflictDoNothing();
return redirect('/machines', { return redirect('/', {
headers: { headers: {
'Set-Cookie': await context.sessions.createSession({ 'Set-Cookie': await context.sessions.createSession({
// TODO: This is breaking, to stop the "over-generation" of API api_key: context.oidcConnector.apiKey,
// keys because they are currently non-deletable in the headscale user: {
// database. Look at this in the future once we have a solution subject: claims.sub,
// or we have permissioned API keys. username,
api_key: context.config.oidc!.headscale_api_key, name,
user, email: userInfo.email,
picture,
},
}), }),
}, },
}); });
} catch (error) { } catch (error) {
return new Response(JSON.stringify(formatError(error)), { if (error instanceof oidc.ResponseBodyError) {
status: 500, log.error(
headers: { 'auth',
'Content-Type': 'application/json', 'Got an OIDC response error body: %s',
}, JSON.stringify(error.cause),
}); );
}
}
type PictureSource = NonNullable<
HeadplaneConfig['oidc']
>['profile_picture_source'];
function setOidcPictureForSource(user: FlowUser, source: PictureSource) {
// Already set by default in the callback, so we can just return it
if (source === 'oidc') {
return user.picture;
}
if (source === 'gravatar') {
if (!user.email) {
return undefined;
} }
const emailHash = user.email.trim().toLowerCase(); if (error instanceof oidc.AuthorizationResponseError) {
const hash = createHash('sha256').update(emailHash).digest('hex'); log.error(
return `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon&r=x`; 'auth',
} 'Got an OIDC authorization response error: %s',
error.error,
);
}
return undefined; if (error instanceof oidc.WWWAuthenticateChallengeError) {
log.error('auth', 'Got an OIDC WWW-Authenticate challenge error');
}
return redirect('/login?s=error_auth_failed');
}
} }
+55 -22
View File
@@ -1,6 +1,16 @@
import { createCookie, type LoaderFunctionArgs, redirect } from 'react-router'; import * as oidc from 'openid-client';
import {
createCookie,
data,
type LoaderFunctionArgs,
redirect,
} from 'react-router';
import type { LoadContext } from '~/server'; import type { LoadContext } from '~/server';
import { beginAuthFlow, getRedirectUri } from '~/utils/oidc';
export interface OidcCookieState {
nonce: string;
state: string;
}
export async function loader({ export async function loader({
request, request,
@@ -8,40 +18,63 @@ export async function loader({
}: LoaderFunctionArgs<LoadContext>) { }: LoaderFunctionArgs<LoadContext>) {
try { try {
await context.sessions.auth(request); await context.sessions.auth(request);
return redirect('/machines'); return redirect('/');
} catch {} } catch {}
if ( if (!context.oidcConnector?.isValid) {
!context.oidc || throw data('OIDC is not enabled or misconfigured', { status: 501 });
typeof context.oidc === 'string' ||
!context.config.oidc
) {
throw new Error('OIDC is not enabled');
} }
const cookie = createCookie('__oidc_auth_flow', { const cookie = createCookie('__oidc_auth_flow', {
httpOnly: true, httpOnly: true,
maxAge: 300, // 5 minutes maxAge: 300,
secure: context.config.server.cookie_secure,
domain: context.config.server.cookie_domain,
}); });
const redirectUri = const redirectUri =
context.config.oidc?.redirect_uri ?? getRedirectUri(request); context.config.oidc?.redirect_uri ?? getRedirectUri(request);
const data = await beginAuthFlow(
context.oidc,
redirectUri,
context.config.oidc.scope,
context.config.oidc.extra_params,
);
return redirect(data.url, { const nonce = oidc.randomNonce();
const state = oidc.randomState();
const url = oidc.buildAuthorizationUrl(context.oidcConnector.client, {
...(context.oidcConnector.extraParams ?? {}),
scope: context.oidcConnector.scope,
redirect_uri: redirectUri,
state,
nonce,
});
return redirect(url.href, {
status: 302, status: 302,
headers: { headers: {
'Set-Cookie': await cookie.serialize({ 'Set-Cookie': await cookie.serialize({
state: data.state, state,
nonce: data.nonce, nonce,
code_verifier: data.codeVerifier, } satisfies OidcCookieState),
redirect_uri: redirectUri,
}),
}, },
}); });
} }
function getRedirectUri(req: Request) {
const url = new URL(`${__PREFIX__}/oidc/callback`, req.url);
let host = req.headers.get('Host');
if (!host) {
host = req.headers.get('X-Forwarded-Host');
}
if (!host) {
throw data(
'Cannot determine redirect URI: no Host or X-Forwarded-Host header',
{
status: 500,
},
);
}
const proto = req.headers.get('X-Forwarded-Proto');
url.protocol = proto ?? 'http:';
url.host = host;
return url.href;
}
+8 -16
View File
@@ -1,26 +1,18 @@
import { ArrowRight } from 'lucide-react'; import { ArrowRight } from 'lucide-react';
import { import { Link as RemixLink } from 'react-router';
LoaderFunctionArgs,
Link as RemixLink,
useLoaderData,
} from 'react-router';
import Link from '~/components/Link'; import Link from '~/components/Link';
import { LoadContext } from '~/server'; import type { Route } from './+types/overview';
export async function loader({ context }: LoaderFunctionArgs<LoadContext>) { export async function loader({ context }: Route.LoaderArgs) {
return { return {
config: context.hs.writable(), config: context.hs.writable(),
oidc: context.oidc isOidcEnabled: context.oidcConnector?.isValid ?? false,
? typeof context.oidc === 'string'
? undefined
: context.oidc
: undefined,
}; };
} }
export default function Page() { export default function Page({
const { config, oidc } = useLoaderData<typeof loader>(); loaderData: { config, isOidcEnabled },
}: Route.ComponentProps) {
return ( return (
<div className="flex flex-col gap-8 max-w-(--breakpoint-lg)"> <div className="flex flex-col gap-8 max-w-(--breakpoint-lg)">
<div className="flex flex-col w-2/3"> <div className="flex flex-col w-2/3">
@@ -51,7 +43,7 @@ export default function Page() {
<ArrowRight className="w-5 h-5 ml-2" /> <ArrowRight className="w-5 h-5 ml-2" />
</div> </div>
</RemixLink> </RemixLink>
{config && oidc ? ( {config && isOidcEnabled ? (
<> <>
<div className="flex flex-col w-2/3"> <div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4"> <h1 className="text-2xl font-medium mb-4">
-33
View File
@@ -1,33 +0,0 @@
# Headplane Server
This code is responsible for all code that is necessary *before* any
web server is started. It is the only part of the code that contains
many side-effects (in this case, importing a module may run code).
# Hierarchy
```
server
├── index.ts: Loads everything and starts the web server.
├── agent/
│ ├── dispatcher.ts: Serializes commands for the agent control fd (stdin).
│ ├── ssh.ts: Manages & multiplexes the active web SSH connections
│ ├── env.ts: Checks the environment variables for custom overrides.
├── config/
│ ├── integration/
│ │ ├── abstract.ts: Defines the abstract class for integrations.
│ │ ├── docker.ts: Contains the Docker integration.
│ │ ├── index.ts: Determines the correct integration to use (if any).
│ │ ├── kubernetes.ts: Contains the Kubernetes integration.
│ │ ├── proc.ts: Contains the Proc integration.
│ ├── env.ts: Checks the environment variables for custom overrides.
│ ├── loader.ts: Checks the configuration file and coalesces with ENV.
│ ├── schema.ts: Defines the schema for the Headplane configuration.
├── headscale/
│ ├── api-client.ts: Creates the HTTP client that talks to the Headscale API.
│ ├── api-error.ts: Contains the ResponseError definition.
│ ├── config-loader.ts: Loads the Headscale configuration (if available).
│ ├── config-schema.ts: Defines the schema for the Headscale configuration.
├── web/
│ ├── agent.ts: Handles setting up the agent WebSocket if needed.
│ ├── oidc.ts: Loads and validates an OIDC configuration (if available).
│ ├── roles.ts: Contains information about authentication permissions.
│ ├── sessions.ts: Initializes the session store and methods to manage it.
+2 -2
View File
@@ -12,7 +12,7 @@ export const pathSupportedKeys = [
] as const; ] as const;
const serverConfig = type({ const serverConfig = type({
host: 'string.ip = "127.0.0.1"', host: 'string.ip = "0.0.0.0"',
port: 'number.integer = 3000', port: 'number.integer = 3000',
data_path: 'string.lower = "/var/lib/headplane/"', data_path: 'string.lower = "/var/lib/headplane/"',
@@ -65,8 +65,8 @@ const oidcConfig = type({
redirect_uri: 'string.url?', redirect_uri: 'string.url?',
disable_api_key_login: 'boolean = false', disable_api_key_login: 'boolean = false',
scope: 'string = "openid email profile"', scope: 'string = "openid email profile"',
extra_params: 'Record<string, string>?',
profile_picture_source: '"oidc" | "gravatar" = "oidc"', profile_picture_source: '"oidc" | "gravatar" = "oidc"',
extra_params: 'Record<string, string>?',
authorization_endpoint: 'string.url?', authorization_endpoint: 'string.url?',
token_endpoint: 'string.url?', token_endpoint: 'string.url?',
+4 -3
View File
@@ -8,7 +8,6 @@ import { createDbClient } from './db/client.server';
import { createHeadscaleInterface } from './headscale/api'; import { createHeadscaleInterface } from './headscale/api';
import { loadHeadscaleConfig } from './headscale/config-loader'; import { loadHeadscaleConfig } from './headscale/config-loader';
import { createHeadplaneAgent } from './hp-agent'; import { createHeadplaneAgent } from './hp-agent';
import { configureOidcAuth } from './web/oidc';
import { createSessionStorage } from './web/sessions'; import { createSessionStorage } from './web/sessions';
declare global { declare global {
@@ -40,6 +39,8 @@ const hsApi = await createHeadscaleInterface(
export type LoadContext = typeof appLoadContext; export type LoadContext = typeof appLoadContext;
import 'react-router'; import 'react-router';
import { createOidcConnector } from './web/oidc-connector';
declare module 'react-router' { declare module 'react-router' {
interface AppLoadContext extends LoadContext {} interface AppLoadContext extends LoadContext {}
} }
@@ -68,8 +69,8 @@ const appLoadContext = {
hsApi, hsApi,
agents, agents,
integration: await loadIntegration(config.integration), integration: await loadIntegration(config.integration),
oidc: config.oidc oidcConnector: config.oidc
? await configureOidcAuth( ? await createOidcConnector(
config.oidc, config.oidc,
hsApi.getRuntimeClient(config.oidc.headscale_api_key), hsApi.getRuntimeClient(config.oidc.headscale_api_key),
) )
+197
View File
@@ -0,0 +1,197 @@
import * as oidc from 'openid-client';
import log from '~/utils/log';
import type { HeadplaneConfig } from '../config/config-schema';
import type { RuntimeApiClient } from '../headscale/api/endpoints';
import { isDataUnauthorizedError } from '../headscale/api/error-client';
export type OidcConfig = NonNullable<HeadplaneConfig['oidc']>;
/**
* Errors that can occur during OIDC connector setup and validation.
*/
export type OidcConnectorError =
| 'INVALID_API_KEY'
| 'MISSING_AUTHORIZATION_ENDPOINT'
| 'MISSING_TOKEN_ENDPOINT'
| 'MISSING_USERINFO_ENDPOINT'
| 'MISSING_REQUIRED_CLAIMS'
| 'UNKNOWN_ERROR';
/**
* Represents a "configured" OIDC setup for Headplane.
* This may include mis-configured versions too and will surface error messages.
*/
export type OidcConnector =
| {
isValid: true;
isExclusive: boolean;
client: oidc.Configuration;
apiKey: string;
scope: string;
extraParams?: Record<string, string>;
}
| {
isValid: false;
isExclusive: false;
errors: OidcConnectorError[];
};
/**
* Creates an OIDC connector based on the configuration and Headscale API.
* This will attempt to validate the configuration and return any errors.
*
* @param config The OIDC configuration.
* @param client The Headscale runtime API client.
* @returns An OIDC connector with validation status.
*/
export async function createOidcConnector(
config: OidcConfig,
client: RuntimeApiClient,
): Promise<OidcConnector> {
// TODO: MEANINGFUL LOGS NOT JUST DEBUG SPAM LOL
//
const errors: OidcConnectorError[] = [];
if (!config.headscale_api_key) {
errors.push('INVALID_API_KEY');
return {
isValid: false,
isExclusive: false,
errors,
};
}
try {
await client.getApiKeys();
} catch (error) {
if (isDataUnauthorizedError(error)) {
errors.push('INVALID_API_KEY');
return {
isValid: false,
isExclusive: false,
errors,
};
}
// MARK: Otherwise assume the API key is valid since the API request
// failed for another reason that isn't 401 and we are optimistic
}
const oidcClientOrErrors = await discoveryCoalesce(config);
if (Array.isArray(oidcClientOrErrors)) {
errors.push(...oidcClientOrErrors);
return {
isValid: false,
isExclusive: false,
errors,
};
}
return {
isValid: true,
isExclusive: config.disable_api_key_login,
client: oidcClientOrErrors,
apiKey: config.headscale_api_key,
scope: config.scope,
extraParams: config.extra_params,
};
}
/**
* Runs OIDC discovery and coalesces the results with the provided config.
* We treat the manually supplied values as overrides to discovery.
*
* @param config The OIDC configuration.
* @returns The coalesced OIDC configuration or an array of errors.
*/
async function discoveryCoalesce(
config: OidcConfig,
): Promise<oidc.Configuration | OidcConnectorError[]> {
let metadata: oidc.ServerMetadata;
try {
const client = await oidc.discovery(
new URL(config.issuer),
config.client_id,
);
metadata = client.serverMetadata();
if (metadata.claims_supported != null) {
if (!metadata.claims_supported.includes('sub')) {
log.error('config', 'OIDC provider does not support `sub` claim');
return ['MISSING_REQUIRED_CLAIMS'];
}
if (!metadata.claims_supported.includes('name')) {
if (
!(
metadata.claims_supported.includes('given_name') &&
metadata.claims_supported.includes('family_name')
)
) {
log.warn(
'config',
'OIDC provider does not support `name`, `given_name`, or `family_name` claims',
);
}
}
if (
!metadata.claims_supported.includes('preferred_username') &&
!metadata.claims_supported.includes('email')
) {
log.warn(
'config',
'OIDC provider does not support `preferred_username` or `email` claims',
);
}
}
} catch {
log.error(
'config',
'Failed to auto-configure OIDC endpoints via discovery',
);
log.warn(
'config',
'OIDC server may not support discovery, using manual config',
);
metadata = {
issuer: config.issuer,
};
}
const errors: OidcConnectorError[] = [];
const authorization_endpoint =
config.authorization_endpoint ?? metadata.authorization_endpoint;
const token_endpoint = config.token_endpoint ?? metadata.token_endpoint;
const userinfo_endpoint =
config.userinfo_endpoint ?? metadata.userinfo_endpoint;
if (!authorization_endpoint) {
errors.push('MISSING_AUTHORIZATION_ENDPOINT');
}
if (!token_endpoint) {
errors.push('MISSING_TOKEN_ENDPOINT');
}
if (!userinfo_endpoint) {
errors.push('MISSING_USERINFO_ENDPOINT');
}
if (errors.length > 0) {
return errors;
}
const oidcClient = new oidc.Configuration(
{
issuer: config.issuer,
authorization_endpoint,
token_endpoint,
userinfo_endpoint,
},
config.client_id,
config.client_secret,
);
return oidcClient;
}
-186
View File
@@ -1,186 +0,0 @@
import * as oidc from 'openid-client';
import log from '~/utils/log';
import { HeadplaneConfig } from '../config/schema';
import type { RuntimeApiClient } from '../headscale/api/endpoints';
import { isDataUnauthorizedError } from '../headscale/api/error-client';
export type OidcConfig = NonNullable<HeadplaneConfig['oidc']>;
export type OidcConfigError = string;
export async function configureOidcAuth(
config: OidcConfig,
client: RuntimeApiClient,
): Promise<oidc.Configuration | OidcConfigError> {
// Don't waste any of our time if the OIDC API key is invalid
try {
await client.getApiKeys();
} catch (error) {
if (isDataUnauthorizedError(error)) {
return [
'The supplied API key for OIDC is invalid.',
'OIDC will be disabled until a valid API key is given',
].join(' ');
}
// MARK: Otherwise assume the API key is valid since the API request
// failed for another reason that isn't 401
}
log.debug('config', 'Running OIDC discovery for %s', config.issuer);
let clientAuthMethod: oidc.ClientAuth;
switch (config.token_endpoint_auth_method) {
case 'client_secret_basic':
clientAuthMethod = oidc.ClientSecretBasic(config.client_secret!);
break;
case 'client_secret_post':
clientAuthMethod = oidc.ClientSecretPost(config.client_secret!);
break;
case 'client_secret_jwt':
clientAuthMethod = oidc.ClientSecretJwt(config.client_secret!);
break;
default:
// MARK: Throwing because this is a developer skill issue
throw new Error('Invalid client authentication method');
}
let oidcClient: oidc.Configuration;
try {
const discovery = await oidc.discovery(
new URL(config.issuer),
config.client_id,
config.client_secret!, // TODO: Fix this config schema
clientAuthMethod,
);
const meta = discovery.serverMetadata();
if (!meta.authorization_endpoint) {
log.error(
'config',
'Issuer discovery did not return `authorization_endpoint`',
);
log.error(
'config',
'OIDC server does not support authorization code flow',
);
log.error('config', 'You may need to set this manually in the config');
return 'OIDC provider did not return `authorization_endpoint`, please check logs';
}
if (!meta.token_endpoint) {
log.error('config', 'Issuer discovery did not return `token_endpoint`');
log.error(
'config',
'OIDC server does not support authorization code flow',
);
log.error('config', 'You may need to set this manually in the config');
return 'OIDC provider did not return `token_endpoint`, please check logs';
}
if (!meta.userinfo_endpoint) {
log.error(
'config',
'Issuer discovery did not return `userinfo_endpoint`',
);
log.error('config', 'OIDC server does not support user info endpoint');
log.error('config', 'You may need to set this manually in the config');
return 'OIDC provider did not return `user_info`, please check logs';
}
if (meta.token_endpoint_auth_methods_supported) {
if (
!meta.token_endpoint_auth_methods_supported.includes(
config.token_endpoint_auth_method,
)
) {
log.error(
'config',
'OIDC server does not support client authentication method %s',
config.token_endpoint_auth_method,
);
log.error(
'config',
'Supported methods: %s',
meta.token_endpoint_auth_methods_supported.join(', '),
);
return [
'Headplane is expecting the following client authencation method:',
config.token_endpoint_auth_method,
'while the OIDC server only supports',
`${meta.token_endpoint_auth_methods_supported.join(', ')}.`,
'OIDC wil be disabled until configured correctly.',
].join(' ');
}
}
log.debug('config', 'OIDC discovery successful');
log.debug(
'config',
'Authorization endpoint: %s',
meta.authorization_endpoint,
);
log.debug('config', 'Token endpoint: %s', meta.token_endpoint);
log.debug('config', 'Userinfo endpoint: %s', meta.userinfo_endpoint);
// Manually construct the endpoints to coalesce with config if needed
oidcClient = new oidc.Configuration(
{
issuer: config.issuer,
authorization_endpoint:
config.authorization_endpoint || meta.authorization_endpoint,
token_endpoint: config.token_endpoint || meta.token_endpoint,
userinfo_endpoint: config.userinfo_endpoint || meta.userinfo_endpoint,
},
config.client_id,
config.client_secret!,
clientAuthMethod,
);
} catch (err) {
log.error('config', 'OIDC discovery failed: %s', err);
log.debug('config', 'Error details: %o', err);
log.error(
'config',
'This may be an error, or the server may not support discovery',
);
if (
!config.authorization_endpoint ||
!config.token_endpoint ||
!config.userinfo_endpoint
) {
log.error(
'config',
'Endpoints are not fully configured, cannot continue',
);
log.error(
'config',
'You must set authorization_endpoint, token_endpoint and userinfo_endpoint manually in the config or fix the discovery issue',
);
return 'OIDC provider could not be configured, please check logs.';
}
oidcClient = new oidc.Configuration(
{
issuer: config.issuer,
authorization_endpoint: config.authorization_endpoint,
token_endpoint: config.token_endpoint,
userinfo_endpoint: config.userinfo_endpoint,
},
config.client_id,
config.client_secret!,
clientAuthMethod,
);
log.debug('config', 'Using manually configured endpoints');
log.debug(
'config',
'Authorization endpoint: %s',
config.authorization_endpoint,
);
log.debug('config', 'Token endpoint: %s', config.token_endpoint);
log.debug('config', 'Userinfo endpoint: %s', config.userinfo_endpoint);
}
log.info('config', 'Successfully configured OIDC authentication');
return oidcClient;
}
-204
View File
@@ -1,204 +0,0 @@
import * as client from 'openid-client';
import { Configuration, IDToken, UserInfoResponse } from 'openid-client';
import log from '~/utils/log';
// We try our best to infer the callback URI of our Headplane instance
// By default it is always /<base_path>/oidc/callback
// (This can ALWAYS be overridden through the OidcConfig)
export function getRedirectUri(req: Request) {
const base = __PREFIX__ ?? '/admin'; // Fallback
const url = new URL(`${base}/oidc/callback`, req.url);
let host = req.headers.get('Host');
if (!host) {
host = req.headers.get('X-Forwarded-Host');
}
if (!host) {
log.error('auth', 'Unable to find a host header');
log.error('auth', 'Ensure either Host or X-Forwarded-Host is set');
throw new Error('Could not determine reverse proxy host');
}
const proto = req.headers.get('X-Forwarded-Proto');
if (!proto) {
log.warn('auth', 'No X-Forwarded-Proto header found');
log.warn('auth', 'Assuming your Headplane instance runs behind HTTP');
}
url.protocol = proto ?? 'http:';
url.host = host;
return url.href;
}
export async function beginAuthFlow(
config: Configuration,
redirect_uri: string,
scope: string,
extra_params: Record<string, string> = {},
) {
const codeVerifier = client.randomPKCECodeVerifier();
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
const params: Record<string, string> = {
...extra_params,
scope,
redirect_uri,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
state: client.randomState(),
};
if (!config.serverMetadata().supportsPKCE()) {
params.nonce = client.randomNonce();
}
const url = client.buildAuthorizationUrl(config, params);
return {
url: url.href,
codeVerifier,
state: params.state,
nonce: params.nonce ?? '<none>',
};
}
interface FlowOptions {
redirect_uri: string;
code_verifier: string;
state: string;
nonce?: string;
}
export interface FlowUser {
subject: string;
name: string;
email: string | undefined;
username: string | undefined;
picture: string | undefined;
}
export async function finishAuthFlow(
config: Configuration,
options: FlowOptions,
): Promise<FlowUser> {
const tokens = await client.authorizationCodeGrant(
config,
new URL(options.redirect_uri),
{
pkceCodeVerifier: options.code_verifier,
expectedNonce: options.nonce,
expectedState: options.state,
idTokenExpected: true,
},
);
const claims = tokens.claims();
if (!claims?.sub) {
throw new Error('No subject found in OIDC claims');
}
const user = await client.fetchUserInfo(
config,
tokens.access_token,
claims.sub,
);
return {
subject: user.sub,
name: getName(user, claims),
email: user.email ?? claims.email?.toString(),
username: calculateUsername(claims, user),
picture: user.picture,
};
}
function calculateUsername(claims: IDToken, user: UserInfoResponse) {
if (user.preferred_username) {
return user.preferred_username;
}
if (
claims.preferred_username &&
typeof claims.preferred_username === 'string'
) {
return claims.preferred_username;
}
if (user.email) {
return user.email.split('@')[0];
}
if (claims.email && typeof claims.email === 'string') {
return claims.email.split('@')[0];
}
return;
}
function getName(user: client.UserInfoResponse, claims: client.IDToken) {
if (user.name) {
return user.name;
}
if (claims.name && typeof claims.name === 'string') {
return claims.name;
}
if (user.given_name && user.family_name) {
return `${user.given_name} ${user.family_name}`;
}
if (user.preferred_username) {
return user.preferred_username;
}
if (
claims.preferred_username &&
typeof claims.preferred_username === 'string'
) {
return claims.preferred_username;
}
return 'Anonymous';
}
export function formatError(error: unknown) {
if (error instanceof client.ResponseBodyError) {
return {
code: error.code,
error: {
name: error.error,
description: error.error_description,
},
};
}
if (error instanceof client.AuthorizationResponseError) {
return {
code: error.code,
error: {
name: error.error,
description: error.error_description,
},
};
}
if (error instanceof client.WWWAuthenticateChallengeError) {
return {
code: error.code,
error: {
name: error.name,
description: error.message,
challenges: error.cause,
},
};
}
log.error('auth', 'Unknown error: %s', error);
return {
code: 500,
error: {
name: 'Internal Server Error',
description: 'An unknown error occurred',
},
};
}
+2 -2
View File
@@ -7,7 +7,7 @@
"scripts": { "scripts": {
"preinstall": "npx only-allow pnpm", "preinstall": "npx only-allow pnpm",
"build": "react-router build", "build": "react-router build",
"dev": "HEADPLANE_LOAD_ENV_OVERRIDES=true HEADPLANE_CONFIG_PATH=./config.example.yaml react-router dev", "dev": "HEADPLANE_CONFIG_PATH=./config.example.yaml react-router dev",
"start": "node build/server/index.js", "start": "node build/server/index.js",
"typecheck": "react-router typegen && tsgo", "typecheck": "react-router typegen && tsgo",
"test:unit": "vitest run --project unit", "test:unit": "vitest run --project unit",
@@ -41,7 +41,7 @@
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/react": "^19.2.5", "@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@typescript/native-preview": "7.0.0-dev.20251116.1", "@typescript/native-preview": "7.0.0-dev.20251203.1",
"@uiw/codemirror-theme-github": "4.25.1", "@uiw/codemirror-theme-github": "4.25.1",
"@uiw/codemirror-theme-xcode": "4.25.3", "@uiw/codemirror-theme-xcode": "4.25.3",
"@uiw/react-codemirror": "4.25.3", "@uiw/react-codemirror": "4.25.3",
+33 -33
View File
@@ -78,8 +78,8 @@ importers:
specifier: ^19.2.3 specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.5) version: 19.2.3(@types/react@19.2.5)
'@typescript/native-preview': '@typescript/native-preview':
specifier: 7.0.0-dev.20251116.1 specifier: 7.0.0-dev.20251203.1
version: 7.0.0-dev.20251116.1 version: 7.0.0-dev.20251203.1
'@uiw/codemirror-theme-github': '@uiw/codemirror-theme-github':
specifier: 4.25.1 specifier: 4.25.1
version: 4.25.1(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.7) version: 4.25.1(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.7)
@@ -2344,43 +2344,43 @@ packages:
'@types/ws@8.18.1': '@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20251116.1': '@typescript/native-preview-darwin-arm64@7.0.0-dev.20251203.1':
resolution: {integrity: sha512-h9IiIyFUzFVOTFCI5DsE0mP70is3lFk6iujnZWpB9Xi2JG2g8H7ltXoBPL43ANPG3OEHQA+N7lEb/t4TliaVFg==} resolution: {integrity: sha512-gMPW/y89KANC0fIqdudxwsxUOHTOyujaOGxyj4IOaFLIP+8/gofawsmdf9HVniPq4xCT7tMpiqa/b9btxJ5nGw==}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@typescript/native-preview-darwin-x64@7.0.0-dev.20251116.1': '@typescript/native-preview-darwin-x64@7.0.0-dev.20251203.1':
resolution: {integrity: sha512-y18xG8zbI8ryCybDRdKc9d7o9eSR38aGNZt02vpEHnPfWgJUP89lTJKhUDm0S2LoEDgVo3ur0Or4jlJh38rPmQ==} resolution: {integrity: sha512-BG/bCAZcTGNM4bQMwY4hI0l70QaxQW9qwJ04GZJL02LAnaQVai8o5X/ghU6awkXFt9CTXdXBWn+hKNb+IiHG+w==}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@typescript/native-preview-linux-arm64@7.0.0-dev.20251116.1': '@typescript/native-preview-linux-arm64@7.0.0-dev.20251203.1':
resolution: {integrity: sha512-eQH1xdStw2yGqLCQtBjNvc5sYI/tYe2qIvpCpmR8LbMWLs5518vYnOrLXMs+PlAZ1zEvnY0qlxm9/4HBacrRpg==} resolution: {integrity: sha512-UGrYYbYbyjlklDubWE93E84WU6jrCGsjpJ2+/GFf4keB3IUrvg9lRqgQ3DUYW4p5kXJR+YC42HnG+OXkN+s6Pw==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@typescript/native-preview-linux-arm@7.0.0-dev.20251116.1': '@typescript/native-preview-linux-arm@7.0.0-dev.20251203.1':
resolution: {integrity: sha512-hyXKS9An1gs+vJI4adTT6COiMbqDy3sHxc7qd2f3FhK4n1Up+Lpo8Psy0615rt0Zu35hKNLtiMZx+ymyrqgQ2A==} resolution: {integrity: sha512-iGrXfVUWtXgiaiVX7FhFVYFz3qFklta2kQEx0VX+km/tBVFMt+egI36tflKYuR7UE5n+0kboKldtyKgmfGrrjQ==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
'@typescript/native-preview-linux-x64@7.0.0-dev.20251116.1': '@typescript/native-preview-linux-x64@7.0.0-dev.20251203.1':
resolution: {integrity: sha512-CRKIu9IlvgmK5rDrLu+WviUy2lrqCFP4p5n3dtpzZuobgwI2TYuRduGDDq+qXgfdAVUpQkZOHUUQvF9PmjrNmw==} resolution: {integrity: sha512-s3VZtFQGktU1ph0q3v8T2tVsgTrRoiaWFknt2vrErxKnzfQgChWOlM0o7Geaj/y1dnrOGWO/cHwMCSb7vd2fgw==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@typescript/native-preview-win32-arm64@7.0.0-dev.20251116.1': '@typescript/native-preview-win32-arm64@7.0.0-dev.20251203.1':
resolution: {integrity: sha512-FljNjkaA/0KMRUkp/ZTqcQNkqc8PNDgxSKm1lirtdkWgVXXurxyKIkKjZdOgxZchOWDwuqy5ZW4epe2ysTm5lQ==} resolution: {integrity: sha512-rcaW7Kn7Ja8J17wmc9UuOjf0LmlqPQYYnqQTdh/kj72FcK4l+8P7b1LcViQFcsOAiIcRZBKrEVZnZXNQxYdHMQ==}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@typescript/native-preview-win32-x64@7.0.0-dev.20251116.1': '@typescript/native-preview-win32-x64@7.0.0-dev.20251203.1':
resolution: {integrity: sha512-a2aDDFlgpPU0vYgmDkeABsWYdKGv1bYtggZ9FpDls8m7FmxdY0i/5SyPhkp/fDgW6qEEiOfcd4e/32zubcl3Gg==} resolution: {integrity: sha512-+DSMCGE7VZWjYzbWDuIenBW2tUclUqAGi7pcOgGq3BpwPEqyVhdQh5ggOk087xgshBv5Wj/yAdXA9gQFAFxpEQ==}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@typescript/native-preview@7.0.0-dev.20251116.1': '@typescript/native-preview@7.0.0-dev.20251203.1':
resolution: {integrity: sha512-ZfMvOGyzDSN3PxUYOSbx3jqszetxLSzLQkS+37oY40gX1YnGK8oZNf4ljVdGqYWpyRloYVREB8YbAY+UxLZxhQ==} resolution: {integrity: sha512-u6kHGmbkB4WQ2XjQUVq6PixV92biRclTBAq8r09L/MGzsiVREdYzf/Bf1W4aTDcDSu6UQ3hjtBR6hROQRPrMXQ==}
hasBin: true hasBin: true
'@uiw/codemirror-extensions-basic-setup@4.25.3': '@uiw/codemirror-extensions-basic-setup@4.25.3':
@@ -7045,36 +7045,36 @@ snapshots:
dependencies: dependencies:
'@types/node': 24.10.1 '@types/node': 24.10.1
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20251116.1': '@typescript/native-preview-darwin-arm64@7.0.0-dev.20251203.1':
optional: true optional: true
'@typescript/native-preview-darwin-x64@7.0.0-dev.20251116.1': '@typescript/native-preview-darwin-x64@7.0.0-dev.20251203.1':
optional: true optional: true
'@typescript/native-preview-linux-arm64@7.0.0-dev.20251116.1': '@typescript/native-preview-linux-arm64@7.0.0-dev.20251203.1':
optional: true optional: true
'@typescript/native-preview-linux-arm@7.0.0-dev.20251116.1': '@typescript/native-preview-linux-arm@7.0.0-dev.20251203.1':
optional: true optional: true
'@typescript/native-preview-linux-x64@7.0.0-dev.20251116.1': '@typescript/native-preview-linux-x64@7.0.0-dev.20251203.1':
optional: true optional: true
'@typescript/native-preview-win32-arm64@7.0.0-dev.20251116.1': '@typescript/native-preview-win32-arm64@7.0.0-dev.20251203.1':
optional: true optional: true
'@typescript/native-preview-win32-x64@7.0.0-dev.20251116.1': '@typescript/native-preview-win32-x64@7.0.0-dev.20251203.1':
optional: true optional: true
'@typescript/native-preview@7.0.0-dev.20251116.1': '@typescript/native-preview@7.0.0-dev.20251203.1':
optionalDependencies: optionalDependencies:
'@typescript/native-preview-darwin-arm64': 7.0.0-dev.20251116.1 '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20251203.1
'@typescript/native-preview-darwin-x64': 7.0.0-dev.20251116.1 '@typescript/native-preview-darwin-x64': 7.0.0-dev.20251203.1
'@typescript/native-preview-linux-arm': 7.0.0-dev.20251116.1 '@typescript/native-preview-linux-arm': 7.0.0-dev.20251203.1
'@typescript/native-preview-linux-arm64': 7.0.0-dev.20251116.1 '@typescript/native-preview-linux-arm64': 7.0.0-dev.20251203.1
'@typescript/native-preview-linux-x64': 7.0.0-dev.20251116.1 '@typescript/native-preview-linux-x64': 7.0.0-dev.20251203.1
'@typescript/native-preview-win32-arm64': 7.0.0-dev.20251116.1 '@typescript/native-preview-win32-arm64': 7.0.0-dev.20251203.1
'@typescript/native-preview-win32-x64': 7.0.0-dev.20251116.1 '@typescript/native-preview-win32-x64': 7.0.0-dev.20251203.1
'@uiw/codemirror-extensions-basic-setup@4.25.3(@codemirror/autocomplete@6.18.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.7)(@lezer/common@1.3.0))(@codemirror/commands@6.10.0)(@codemirror/language@6.11.3)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.7)(@codemirror/state@6.5.2)(@codemirror/view@6.38.7)': '@uiw/codemirror-extensions-basic-setup@4.25.3(@codemirror/autocomplete@6.18.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.38.7)(@lezer/common@1.3.0))(@codemirror/commands@6.10.0)(@codemirror/language@6.11.3)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.7)(@codemirror/state@6.5.2)(@codemirror/view@6.38.7)':
dependencies: dependencies: