feat: initial auth rework

This commit is contained in:
Aarnav Tale
2026-03-07 17:10:34 -05:00
parent 6d70497758
commit 0f8e192b5c
39 changed files with 2110 additions and 1684 deletions
+8 -6
View File
@@ -1,15 +1,17 @@
import { data } from "react-router";
import { getOidcSubject } from "~/server/web/headscale-identity";
import { Capabilities } from "~/server/web/roles";
import type { Route } from "./+types/overview";
export async function authKeysAction({ request, context }: Route.ActionArgs) {
const session = await context.sessions.auth(request);
const api = context.hsApi.getRuntimeClient(session.api_key);
const principal = await context.auth.require(request);
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
const canGenerateAny = await context.sessions.check(request, Capabilities.generate_authkeys);
const canGenerateOwn = await context.sessions.check(request, Capabilities.generate_own_authkeys);
const canGenerateAny = context.auth.can(principal, Capabilities.generate_authkeys);
const canGenerateOwn = context.auth.can(principal, Capabilities.generate_own_authkeys);
if (!canGenerateAny && !canGenerateOwn) {
throw data("You do not have permission to manage pre-auth keys", {
@@ -23,8 +25,8 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) {
if (!targetUser) {
throw data("User not found.", { status: 404 });
}
const targetSubject = targetUser.providerId?.split("/").pop();
if (targetSubject !== session.user.subject) {
const targetSubject = getOidcSubject(targetUser);
if (principal.kind !== "oidc" || targetSubject !== principal.user.subject) {
throw data("You do not have permission to manage this user's pre-auth keys", {
status: 403,
});
@@ -17,11 +17,15 @@ interface AddAuthKeyProps {
users: User[];
url: string;
selfServiceOnly: boolean;
currentSubject: string;
currentSubject?: string;
}
function findCurrentUser(users: User[], subject: string): User | undefined {
return users.find((u) => u.providerId?.split("/").pop() === subject);
function findCurrentUser(users: User[], subject: string | undefined): User | undefined {
if (!subject) return undefined;
return users.find((u) => {
if (u.provider !== "oidc" || !u.providerId) return false;
return u.providerId.split("/").pop() === subject;
});
}
export default function AddAuthKey({
+6 -5
View File
@@ -19,8 +19,9 @@ import AuthKeyRow from "./auth-key-row";
import AddAuthKey from "./dialogs/add-auth-key";
export async function loader({ request, context }: Route.LoaderArgs) {
const session = await context.sessions.auth(request);
const api = context.hsApi.getRuntimeClient(session.api_key);
const principal = await context.auth.require(request);
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
const users = await api.getUsers();
@@ -83,8 +84,8 @@ export async function loader({ request, context }: Route.LoaderArgs) {
.map(({ user, error }) => ({ user, error }));
}
const canGenerateAny = await context.sessions.check(request, Capabilities.generate_authkeys);
const canGenerateOwn = await context.sessions.check(request, Capabilities.generate_own_authkeys);
const canGenerateAny = context.auth.can(principal, Capabilities.generate_authkeys);
const canGenerateOwn = context.auth.can(principal, Capabilities.generate_own_authkeys);
return {
keys,
@@ -92,7 +93,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
users,
access: canGenerateAny || canGenerateOwn,
selfServiceOnly: !canGenerateAny && canGenerateOwn,
currentSubject: session.user.subject,
currentSubject: principal.kind === "oidc" ? principal.user.subject : undefined,
url: context.config.headscale.public_url ?? context.config.headscale.url,
};
}
+1 -1
View File
@@ -6,7 +6,7 @@ import Link from "~/components/Link";
import type { Route } from "./+types/overview";
export async function loader({ context }: Route.LoaderArgs) {
const oidcConnector = await context.oidcConnector?.get();
const oidcConnector = await context.oidc?.connector.get();
return {
config: context.hs.writable(),
isOidcEnabled: oidcConnector?.isValid ?? false,
+160 -169
View File
@@ -1,198 +1,189 @@
import { data } from 'react-router';
import { Capabilities } from '~/server/web/roles';
import type { Route } from './+types/overview';
import { data } from "react-router";
export async function restrictionAction({
request,
context,
}: Route.ActionArgs) {
const check = await context.sessions.check(
request,
Capabilities.configure_iam,
);
import { Capabilities } from "~/server/web/roles";
if (!check) {
throw data('You do not have permission to modify IAM settings.', {
status: 403,
});
}
import type { Route } from "./+types/overview";
if (!context.hs.writable()) {
throw data('The Headscale configuration file is not editable.', {
status: 403,
});
}
export async function restrictionAction({ request, context }: Route.ActionArgs) {
const principal = await context.auth.require(request);
const check = context.auth.can(principal, Capabilities.configure_iam);
const formData = await request.formData();
const action = formData.get('action_id')?.toString();
if (!action) {
throw data('No action provided.', {
status: 400,
});
}
if (!check) {
throw data("You do not have permission to modify IAM settings.", {
status: 403,
});
}
// We only need healthchecks which don't rely on an API key
const api = context.hsApi.getRuntimeClient('fake-api-key');
switch (action) {
case 'add_domain': {
const domain = formData.get('domain')?.toString()?.trim();
if (!domain) {
throw data('No domain provided.', {
status: 400,
});
}
if (!context.hs.writable()) {
throw data("The Headscale configuration file is not editable.", {
status: 403,
});
}
const domains = [
...new Set([...(context.hs.c?.oidc?.allowed_domains ?? []), domain]),
];
const formData = await request.formData();
const action = formData.get("action_id")?.toString();
if (!action) {
throw data("No action provided.", {
status: 400,
});
}
await context.hs.patch([
{
path: 'oidc.allowed_domains',
value: domains,
},
]);
// We only need healthchecks which don't rely on an API key
const api = context.hsApi.getRuntimeClient("fake-api-key");
switch (action) {
case "add_domain": {
const domain = formData.get("domain")?.toString()?.trim();
if (!domain) {
throw data("No domain provided.", {
status: 400,
});
}
context.integration?.onConfigChange(api);
return data('Domain added successfully.');
}
const domains = [...new Set([...(context.hs.c?.oidc?.allowed_domains ?? []), domain])];
case 'remove_domain': {
const domain = formData.get('domain')?.toString()?.trim();
if (!domain) {
throw data('No domain provided.', {
status: 400,
});
}
await context.hs.patch([
{
path: "oidc.allowed_domains",
value: domains,
},
]);
const storedDomains = context.hs.c?.oidc?.allowed_domains ?? [];
if (!storedDomains.includes(domain)) {
// Domain not found in the list
throw data(`Domain "${domain}" not found in allowed domains.`, {
status: 400,
});
}
context.integration?.onConfigChange(api);
return data("Domain added successfully.");
}
// Filter out the domain to remove it from the list
const domains = storedDomains.filter((d: string) => d !== domain);
await context.hs.patch([
{
path: 'oidc.allowed_domains',
value: domains,
},
]);
context.integration?.onConfigChange(api);
return data('Domain removed successfully.');
}
case "remove_domain": {
const domain = formData.get("domain")?.toString()?.trim();
if (!domain) {
throw data("No domain provided.", {
status: 400,
});
}
case 'add_group': {
const group = formData.get('group')?.toString()?.trim();
if (!group) {
throw data('No group provided.', {
status: 400,
});
}
const storedDomains = context.hs.c?.oidc?.allowed_domains ?? [];
if (!storedDomains.includes(domain)) {
// Domain not found in the list
throw data(`Domain "${domain}" not found in allowed domains.`, {
status: 400,
});
}
const groups = [
...new Set([...(context.hs.c?.oidc?.allowed_groups ?? []), group]),
];
// Filter out the domain to remove it from the list
const domains = storedDomains.filter((d: string) => d !== domain);
await context.hs.patch([
{
path: "oidc.allowed_domains",
value: domains,
},
]);
context.integration?.onConfigChange(api);
return data("Domain removed successfully.");
}
await context.hs.patch([
{
path: 'oidc.allowed_groups',
value: groups,
},
]);
case "add_group": {
const group = formData.get("group")?.toString()?.trim();
if (!group) {
throw data("No group provided.", {
status: 400,
});
}
context.integration?.onConfigChange(api);
return data('Group added successfully.');
}
const groups = [...new Set([...(context.hs.c?.oidc?.allowed_groups ?? []), group])];
case 'remove_group': {
const group = formData.get('group')?.toString()?.trim();
if (!group) {
throw data('No group provided.', {
status: 400,
});
}
await context.hs.patch([
{
path: "oidc.allowed_groups",
value: groups,
},
]);
const storedGroups = context.hs.c?.oidc?.allowed_groups ?? [];
if (!storedGroups.includes(group)) {
// Group not found in the list
throw data(`Group "${group}" not found in allowed groups.`, {
status: 400,
});
}
context.integration?.onConfigChange(api);
return data("Group added successfully.");
}
// Filter out the group to remove it from the list
const groups = storedGroups.filter((d: string) => d !== group);
await context.hs.patch([
{
path: 'oidc.allowed_groups',
value: groups,
},
]);
case "remove_group": {
const group = formData.get("group")?.toString()?.trim();
if (!group) {
throw data("No group provided.", {
status: 400,
});
}
context.integration?.onConfigChange(api);
return data('Group removed successfully.');
}
const storedGroups = context.hs.c?.oidc?.allowed_groups ?? [];
if (!storedGroups.includes(group)) {
// Group not found in the list
throw data(`Group "${group}" not found in allowed groups.`, {
status: 400,
});
}
case 'add_user': {
const user = formData.get('user')?.toString()?.trim();
if (!user) {
throw data('No user provided.', {
status: 400,
});
}
// Filter out the group to remove it from the list
const groups = storedGroups.filter((d: string) => d !== group);
await context.hs.patch([
{
path: "oidc.allowed_groups",
value: groups,
},
]);
const users = [
...new Set([...(context.hs.c?.oidc?.allowed_users ?? []), user]),
];
context.integration?.onConfigChange(api);
return data("Group removed successfully.");
}
await context.hs.patch([
{
path: 'oidc.allowed_users',
value: users,
},
]);
case "add_user": {
const user = formData.get("user")?.toString()?.trim();
if (!user) {
throw data("No user provided.", {
status: 400,
});
}
context.integration?.onConfigChange(api);
return data('User added successfully.');
}
const users = [...new Set([...(context.hs.c?.oidc?.allowed_users ?? []), user])];
case 'remove_user': {
const user = formData.get('user')?.toString()?.trim();
if (!user) {
throw data('No user provided.', {
status: 400,
});
}
await context.hs.patch([
{
path: "oidc.allowed_users",
value: users,
},
]);
const storedUsers = context.hs.c?.oidc?.allowed_users ?? [];
if (!storedUsers.includes(user)) {
// User not found in the list
throw data(`User "${user}" not found in allowed users.`, {
status: 400,
});
}
context.integration?.onConfigChange(api);
return data("User added successfully.");
}
// Filter out the user to remove it from the list
const users = storedUsers.filter((d: string) => d !== user);
await context.hs.patch([
{
path: 'oidc.allowed_users',
value: users,
},
]);
case "remove_user": {
const user = formData.get("user")?.toString()?.trim();
if (!user) {
throw data("No user provided.", {
status: 400,
});
}
context.integration?.onConfigChange(api);
return data('User removed successfully.');
}
const storedUsers = context.hs.c?.oidc?.allowed_users ?? [];
if (!storedUsers.includes(user)) {
// User not found in the list
throw data(`User "${user}" not found in allowed users.`, {
status: 400,
});
}
default: {
throw data('Invalid action provided.', {
status: 400,
});
}
}
// Filter out the user to remove it from the list
const users = storedUsers.filter((d: string) => d !== user);
await context.hs.patch([
{
path: "oidc.allowed_users",
value: users,
},
]);
context.integration?.onConfigChange(api);
return data("User removed successfully.");
}
default: {
throw data("Invalid action provided.", {
status: 400,
});
}
}
}
+81 -98
View File
@@ -1,108 +1,91 @@
import { data, Link as RemixLink } from 'react-router';
import Link from '~/components/Link';
import Notice from '~/components/Notice';
import { Capabilities } from '~/server/web/roles';
import type { Route } from './+types/overview';
import { restrictionAction } from './actions';
import AddDomain from './dialogs/add-domain';
import AddGroup from './dialogs/add-group';
import AddUser from './dialogs/add-user';
import RestrictionTable from './table';
import { data, Link as RemixLink } from "react-router";
import Link from "~/components/Link";
import Notice from "~/components/Notice";
import { Capabilities } from "~/server/web/roles";
import type { Route } from "./+types/overview";
import { restrictionAction } from "./actions";
import AddDomain from "./dialogs/add-domain";
import AddGroup from "./dialogs/add-group";
import AddUser from "./dialogs/add-user";
import RestrictionTable from "./table";
export async function loader({ request, context }: Route.LoaderArgs) {
const check = await context.sessions.check(request, Capabilities.read_users);
if (!check) {
throw data('You do not have permission to view IAM settings.', {
status: 403,
});
}
const principal = await context.auth.require(request);
const check = context.auth.can(principal, Capabilities.read_users);
if (!check) {
throw data("You do not have permission to view IAM settings.", {
status: 403,
});
}
if (!context.hs.c?.oidc) {
throw data('OIDC is not configured on this Headscale instance.', {
status: 501,
});
}
if (!context.hs.c?.oidc) {
throw data("OIDC is not configured on this Headscale instance.", {
status: 501,
});
}
return {
access: await context.sessions.check(request, Capabilities.configure_iam),
writable: context.hs.writable(),
settings: {
domains: [...new Set(context.hs.c.oidc.allowed_domains)],
groups: [...new Set(context.hs.c.oidc.allowed_groups)],
users: [...new Set(context.hs.c.oidc.allowed_users)],
},
};
return {
access: context.auth.can(principal, Capabilities.configure_iam),
writable: context.hs.writable(),
settings: {
domains: [...new Set(context.hs.c.oidc.allowed_domains)],
groups: [...new Set(context.hs.c.oidc.allowed_groups)],
users: [...new Set(context.hs.c.oidc.allowed_users)],
},
};
}
export const action = restrictionAction;
export default function Page({
loaderData: { access, writable, settings },
}: Route.ComponentProps) {
const isDisabled = writable ? !access : true;
export default function Page({ loaderData: { access, writable, settings } }: Route.ComponentProps) {
const isDisabled = writable ? !access : true;
return (
<div className="flex flex-col gap-4 max-w-(--breakpoint-lg)">
<div className="flex flex-col w-full sm:w-2/3">
<p className="mb-4 text-md">
<RemixLink className="font-medium" to="/settings">
Settings
</RemixLink>
<span className="mx-2">/</span> Authentication Restrictions
</p>
{!access ? (
<Notice
title="Authentication permissions restricted"
variant="warning"
>
You do not have the necessary permissions to edit the Authentication
Restrictions settings. Please contact your administrator to request
access or to make changes to these settings.
</Notice>
) : !writable ? (
<Notice title="Configuration Locked" variant="error">
The Headscale configuration file is not editable through the web
interface. Please ensure that you have correctly given Headplane
write access to the file.
</Notice>
) : undefined}
<h1 className="text-2xl font-medium mb-2 mt-4">
Authentication Restrictions
</h1>
<p>
Headscale supports restricting OIDC authentication to only allow
certain email domains, groups, or users to authenticate. This can be
used to limit access to your Tailnet to only certain users or groups
and Headplane will also respect these settings when authenticating.{' '}
<Link
name="Headscale OIDC documentation"
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
>
Learn More
</Link>
</p>
</div>
<RestrictionTable
isDisabled={isDisabled}
type="domain"
values={settings.domains}
>
<AddDomain domains={settings.domains} isDisabled={isDisabled} />
</RestrictionTable>
<RestrictionTable
isDisabled={isDisabled}
type="group"
values={settings.groups}
>
<AddGroup groups={settings.groups} isDisabled={isDisabled} />
</RestrictionTable>
<RestrictionTable
isDisabled={isDisabled}
type="user"
values={settings.users}
>
<AddUser isDisabled={isDisabled} users={settings.users} />
</RestrictionTable>
</div>
);
return (
<div className="flex max-w-(--breakpoint-lg) flex-col gap-4">
<div className="flex w-full flex-col sm:w-2/3">
<p className="text-md mb-4">
<RemixLink className="font-medium" to="/settings">
Settings
</RemixLink>
<span className="mx-2">/</span> Authentication Restrictions
</p>
{!access ? (
<Notice title="Authentication permissions restricted" variant="warning">
You do not have the necessary permissions to edit the Authentication Restrictions
settings. Please contact your administrator to request access or to make changes to
these settings.
</Notice>
) : !writable ? (
<Notice title="Configuration Locked" variant="error">
The Headscale configuration file is not editable through the web interface. Please
ensure that you have correctly given Headplane write access to the file.
</Notice>
) : undefined}
<h1 className="mt-4 mb-2 text-2xl font-medium">Authentication Restrictions</h1>
<p>
Headscale supports restricting OIDC authentication to only allow certain email domains,
groups, or users to authenticate. This can be used to limit access to your Tailnet to only
certain users or groups and Headplane will also respect these settings when
authenticating.{" "}
<Link
name="Headscale OIDC documentation"
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
>
Learn More
</Link>
</p>
</div>
<RestrictionTable isDisabled={isDisabled} type="domain" values={settings.domains}>
<AddDomain domains={settings.domains} isDisabled={isDisabled} />
</RestrictionTable>
<RestrictionTable isDisabled={isDisabled} type="group" values={settings.groups}>
<AddGroup groups={settings.groups} isDisabled={isDisabled} />
</RestrictionTable>
<RestrictionTable isDisabled={isDisabled} type="user" values={settings.users}>
<AddUser isDisabled={isDisabled} users={settings.users} />
</RestrictionTable>
</div>
);
}