mirror of
https://github.com/tale/headplane.git
synced 2026-08-11 06:16:52 +00:00
feat: cleanup oidc logic and surface errors better
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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
@@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
Generated
+33
-33
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user