feat: add lazy retrying oidc connector

This commit is contained in:
Aarnav Tale
2026-01-16 00:33:10 -05:00
parent 2618cdd9f6
commit 403f9c9b0b
9 changed files with 984 additions and 911 deletions
+67 -27
View File
@@ -1,4 +1,5 @@
# 0.6.2 (Next)
- Added search and sortable columns to the machines list page (closes [#351](https://github.com/tale/headplane/issues/351)).
- Added support for Headscale 0.27.0 and 0.27.1
- Bundle all `node_modules` aside from native ones to reduce bundle and container size (closes [#331](https://github.com/tale/headplane/issues/331)).
@@ -7,13 +8,13 @@
- Attempt to warn against misconfigured cookie settings on the login page.
- Made `server.cookie_max_age` and `server.cookie_domain` configurable (closes [#348](https://github.com/tale/headplane/issues/348)).
- Re-worked the configuration loading system with several enhancements:
- It is now possible to skip a configuration file and only use environment variables (closes [#150](https://github.com/tale/headplane/issues/150)).
- Secret path loading has been reworked from the ground up to be more reliable (closes [#334](https://github.com/tale/headplane/issues/334)).
- Added better testing and validation for configuration loading
- It is now possible to skip a configuration file and only use environment variables (closes [#150](https://github.com/tale/headplane/issues/150)).
- Secret path loading has been reworked from the ground up to be more reliable (closes [#334](https://github.com/tale/headplane/issues/334)).
- Added better testing and validation for configuration loading
- Re-worked the OIDC integration to adhere to the correct standards and surface more errors to the user.
- Deprecated `oidc.redirect_uri` and automated callback URL detection in favor of setting `server.base_url` correctly.
- Explicitly added `oidc.use_pkce` to correctly determine PKCE configuration.
- `oidc.token_endpoint_auth_method` is now optional and will attempt to be auto-detected, defaulting to `client_secret_basic` if unavailable (closes [#410](https://github.com/tale/headplane/issues/410)).
- Deprecated `oidc.redirect_uri` and automated callback URL detection in favor of setting `server.base_url` correctly.
- Explicitly added `oidc.use_pkce` to correctly determine PKCE configuration.
- `oidc.token_endpoint_auth_method` is now optional and will attempt to be auto-detected, defaulting to `client_secret_basic` if unavailable (closes [#410](https://github.com/tale/headplane/issues/410)).
- Removed several unnecessarily verbose or spammy log messages.
- Updated the minimum Docker API used to support the latest Docker versions (via [#370](https://github.com/tale/headplane/pull/370)).
- Enhanced the node tag dialog to show a dropdown of assignable tags (via [#362](https://github.com/tale/headplane/pull/362)).
@@ -25,18 +26,22 @@
- Machine key registration no longer works if the key isn't 24 characters long (closes [#415](https://github.com/tale/headplane/issues/415)).
- Fixed some mobile CSS issues across the application (closes [#401](https://github.com/tale/headplane/issues/401)).
- Added a Docker healthcheck to the container (closes [#411](https://github.com/tale/headplane/issues/411)).
- Strengthened the validation for the `/proc` integration to correctly discover the Headscale PID.
- Added lazy retry logic for OIDC providers if they initially fail to respond (closes [#423](https://github.com/tale/headplane/issues/423)).
---
# 0.6.1 (October 12, 2025)
- **Headplane now supports connecting to machines via SSH in the web browser.**
- This is an experimental feature and requires the `integration.agent` section to be set up in the config file.
- This is built on top of a Go binary that runs in WebAssembly, using Xterm.js for the terminal interface.
- This is an experimental feature and requires the `integration.agent` section to be set up in the config file.
- This is built on top of a Go binary that runs in WebAssembly, using Xterm.js for the terminal interface.
- Begin using a new SQLite database file in `/var/lib/headplane/hp_persist.db`.
- The database is created automatically if it does not exist.
- It currently stores SSH connection details and HostInfo for the agent.
- User information is automatically migrated from the previous database.
- The database is created automatically if it does not exist.
- It currently stores SSH connection details and HostInfo for the agent.
- User information is automatically migrated from the previous database.
- The docker container now runs in a distroless image (closes [#255](https://github.com/tale/headplane/issues/255)).
- A debug version of the container that runs as root and has a shell is available as `ghcr.io/tale/headplane:<version>-shell`.
- A debug version of the container that runs as root and has a shell is available as `ghcr.io/tale/headplane:<version>-shell`.
- Removing a Split DNS record will no longer make the split domain unresolvable by clients (closes [#231](https://github.com/tale/headplane/issues/231)).
- Reintroduce the toggle for overriding local DNS settings in the Headscale config (closes [#236](https://github.com/tale/headplane/issues/236)).
- Prefer cross-compiling in the Dockerfile to speed up builds while still supporting multiple architectures.
@@ -44,32 +49,33 @@
- Implement more accurate guessing on the PID with the `/proc` integration (via [#219](https://github.com/tale/headplane/pull/219)).
- Usernames will now correctly fall back to emails if not provided (via [#257](https://github.com/tale/headplane/pull/257)).
- Configuration loading via paths is now supported for sensitive values (via [#283](https://github.com/tale/headplane/pulls/283))
- Options like `server.cookie_secret_path` can override `server.cookie_secret`
- Environment variables are interpolatable into these paths
- See the full reference in the [docs](https://github.com/tale/headplane/blob/main/docs/Configuration.md#sensitive-values)
- Options like `server.cookie_secret_path` can override `server.cookie_secret`
- Environment variables are interpolatable into these paths
- See the full reference in the [docs](https://github.com/tale/headplane/blob/main/docs/Configuration.md#sensitive-values)
- The nix overlay build is fixed for the SSH module (via [#282](https://github.com/tale/headplane/pull/282))
- Switch our build processes to use TypeScript Go and Rolldown Vite for better build and type-check performance.
- Cookies are now encrypted JWTs, preserving API key secrets (*GHSA-wrqq-v7qw-r5w7*)
- Cookies are now encrypted JWTs, preserving API key secrets (_GHSA-wrqq-v7qw-r5w7_)
- OIDC profile pictures are now available from Gravatar by setting `oidc.profile_picture_source` to `gravatar` (closes [#232](https://github.com/tale/headplane/issues/232)).
- OIDC now allows passing many custom parameters:
- `oidc.authorization_endpoint`, `oidc.token_endpoint`, and `oidc.userinfo_endpoint` can be overridden to support non-standard providers or scenarios without discovery (closes [#117](https://github.com/tale/headplane/issues/117)).
- `oidc.scope` can be set to specify custom scopes (defaults to `openid email profile`).
- `oidc.extra_params` can be set to pass arbitrary query parameters to the authorization endpoint (closes [#197](https://github.com/tale/headplane/issues/197)).
- `oidc.authorization_endpoint`, `oidc.token_endpoint`, and `oidc.userinfo_endpoint` can be overridden to support non-standard providers or scenarios without discovery (closes [#117](https://github.com/tale/headplane/issues/117)).
- `oidc.scope` can be set to specify custom scopes (defaults to `openid email profile`).
- `oidc.extra_params` can be set to pass arbitrary query parameters to the authorization endpoint (closes [#197](https://github.com/tale/headplane/issues/197)).
---
# 0.6.0 (May 25, 2025)
- Headplane 0.6.0 now requires **Headscale 0.26.0** or newer.
- Breaking API changes with routes and pre auth keys are now supported (closes [#204](https://github.com/tale/headplane/issues/204)).
- Older versions of Headscale will not work with Headplane.
- Breaking API changes with routes and pre auth keys are now supported (closes [#204](https://github.com/tale/headplane/issues/204)).
- Older versions of Headscale will not work with Headplane.
- OIDC authorization restrictions can now be controlled from the settings UI. (closes [#102](https://github.com/tale/headplane/issues/102))
- The required permission role for this is **IT Admin** or **Admin/Owner** and require the Headscale configuration.
- Changes made will modify the `oidc.allowed_{domains,groups,users}` fields in the Headscale config file.
- The required permission role for this is **IT Admin** or **Admin/Owner** and require the Headscale configuration.
- Changes made will modify the `oidc.allowed_{domains,groups,users}` fields in the Headscale config file.
- The Pre-Auth keys page has been fully reworked (closes [#179](https://github.com/tale/headplane/issues/179), [#143](https://github.com/tale/headplane/issues/143)).
- The Headplane agent is now available as an integration (closes [#65](https://github.com/tale/headplane/issues/65)).
- The agent runs as an embedded process alongside the Headplane server and reports host information and system metrics.
- Refer to the `integrations.agent` section of the config file for more information and how to enable it.
- The agent runs as an embedded process alongside the Headplane server and reports host information and system metrics.
- Refer to the `integrations.agent` section of the config file for more information and how to enable it.
- Requests to `/admin` will now be redirected to `/admin/` to prevent issues with the React Router (works with custom prefixes, closes [#173](https://github.com/tale/headplane/issues/173)).
- The Login page has been simplified and separately reports errors versus incorrect API keys (closes [#186](https://github.com/tale/headplane/issues/186)).
- The machine actions backend has been reworked to better handle errors and provide more information to the user (closes [#185](https://github.com/tale/headplane/issues/185)).
@@ -86,11 +92,13 @@
---
# 0.5.10 (April 4, 2025)
- Fix an issue where other preferences to skip onboarding affected every user.
---
# 0.5.9 (April 3, 2025)
- Filter out empty users from the pre-auth keys page which could possibly cause a crash with unmigrated users.
- OIDC users cannot be renamed, so that functionality has been disabled in the menu options.
- Suppress hydration errors for any fields with a date in it.
@@ -98,6 +106,7 @@
---
# 0.5.8 (April 3, 2025)
- You can now skip the onboarding page if desired.
- Added the UI to change user roles in the dashboard.
- Fixed an issue where integrations would throw instead of loading properly.
@@ -108,6 +117,7 @@
---
# 0.5.7 (April 2, 2025)
- Hotfix an issue where assets aren't served under `/admin` or the prefix.
---
@@ -115,9 +125,10 @@
# 0.5.6 (April 2, 2025)
### IMPORTANT
> **PLEASE** update to this ASAP if you were using Google OIDC. This is because previously *ANY* accounts have admin access to your Tailnet if they discover the URL that Headplane is being hosted on. This new change enforces that new logins by default are not given any permissions. You will need to re-login to Headplane to generate an owner account and prevent unauthorized access.
Implemented *proper* authentication methods for OIDC.
> **PLEASE** update to this ASAP if you were using Google OIDC. This is because previously _ANY_ accounts have admin access to your Tailnet if they discover the URL that Headplane is being hosted on. This new change enforces that new logins by default are not given any permissions. You will need to re-login to Headplane to generate an owner account and prevent unauthorized access.
Implemented _proper_ authentication methods for OIDC.
This is a large update and copies the permission system from Tailscale.
Permissions are not automatically derived from OIDC, but they can be configured via the UI.
Additionally, certain roles give certain capabilities, limiting access to parts of the dashboard.
@@ -125,6 +136,7 @@ By default, new users will have a `member` role which forbids access to the UI.
If there are no users, the first user will be given an `owner` role which cannot be removed.
**Changes**:
- Switched the internal server to use `hono` for better performance.
- Fixed an issue that caused dialogs to randomly refocus every 3 seconds.
- Headplane will not send API requests when the tab is not focused.
@@ -135,11 +147,13 @@ If there are no users, the first user will be given an `owner` role which cannot
---
# 0.5.5 (March 18, 2025)
- Hotfix an issue that caused Headplane to crash if no agents are available
---
# 0.5.4 (March 18, 2025)
- Fixed a typo in the Kubernetes documentation
- Handle split and global DNS records not being set in the Headscale config (via [#129](https://github.com/tale/headplane/pull/129))
- Stop checking for the `mkey:` prefix on machine registration (via [#131](https://github.com/tale/headplane/pull/131))
@@ -156,6 +170,7 @@ If there are no users, the first user will be given an `owner` role which cannot
---
# 0.5.3 (March 1, 2025)
- Fixed an issue where Headplane expected the incorrect config value for OIDC scope (fixes [#111](https://github.com/tale/headplane/issues/111))
- Added an ARIA indicator for when an input is required and fixed the confirm buttons (fixed [#116](https://github.com/tale/headplane/issues/116))
- Fixed a typo in the docs that defaulted to `/var/run/docker.dock` for the Docker socket (via [#112](https://github.com/tale/headplane/pull/112))
@@ -163,11 +178,13 @@ If there are no users, the first user will be given an `owner` role which cannot
---
# 0.5.2 (February 28, 2025)
- Hotfixed an issue where the server bundle got reloaded on each request
---
# 0.5.1 (February 28, 2025)
- Fixed an issue that caused the entire server to crash on start
- Fixed the published semver tags from Docker
- Fixed the Kubernetes integration not reading the config
@@ -175,6 +192,7 @@ If there are no users, the first user will be given an `owner` role which cannot
---
# 0.5 (February 27, 2025)
> This release is a major overhaul and contains a significant breaking change.
> We now use a config file for all settings instead of environment variables.
> Please see [config.example.yaml](/config.example.yaml) for the new format.
@@ -192,11 +210,13 @@ If there are no users, the first user will be given an `owner` role which cannot
---
# 0.4.1 (January 18, 2025)
- Fixed an urgent issue where the OIDC redirect URI would mismatch.
---
# 0.4.0 (January 18, 2025)
- Switched from Remix.run to React-Router
- Fixed an issue where some config fields were marked as required even if they weren't (fixes [#66](https://github.com/tale/headplane/issues/66))
- Fixed an issue where the toasts would be obscured by the footer (fixes [#68](https://github.com/tale/headplane/issues/68))
@@ -212,11 +232,13 @@ If there are no users, the first user will be given an `owner` role which cannot
---
# 0.3.9 (December 6, 2024)
- Fixed a race condition bug in the OIDC validation code
---
# 0.3.8 (December 6, 2024)
- Added a little HTML footer to show the login page and link to a donation page.
- Allow creating pre-auth keys that expire past 90 days (fixes [#58](https://github.com/tale/headplane/issues/58))
- Validates OIDC config and ignores validation if specified via variables or Headscale config (fixes [#63](https://github.com/tale/headplane/issues/63))
@@ -224,6 +246,7 @@ If there are no users, the first user will be given an `owner` role which cannot
---
# 0.3.7 (November 30, 2024)
- Allow customizing the OIDC token endpoint auth method via `OIDC_CLIENT_SECRET_METHOD` (fixes [#57](https://github.com/tale/headplane/issues/57))
- Added a `/healthz` endpoint for Kubernetes and other health checks (fixes [#59](https://github.com/tale/headplane/issues/59))
- Allow `HEADSCALE_PUBLIC_URL` to be set if `HEADSCALE_URL` points to a different internal address (fixes [#60](https://github.com/tale/headplane/issues/60))
@@ -232,18 +255,21 @@ If there are no users, the first user will be given an `owner` role which cannot
---
# 0.3.6 (November 20, 2024)
- Fixed an issue where select dropdowns would not scroll (fixes [#53](https://github.com/tale/headplane/issues/53))
- Added a button to copy the machine registration command to the clipboard (fixes [#52](https://github.com/tale/headplane/issues/52))
---
# 0.3.5 (November 8, 2024)
- Quickfix a bug where environment variables are ignored on the server.
- Remove a nagging error about missing cookie since that happens when signed out.
---
# 0.3.4 (November 7, 2024)
- Clicking on the machine name in the users page now takes you to the machine overview page.
- Completely rebuilt the production server to work better outside of Docker and be lighter. More specifically, we've switched from the `@remix-run/serve` package to our own custom built server.
- Fixed a bunch of silly issues introduced by me not typechecking the codebase.
@@ -256,6 +282,7 @@ If there are no users, the first user will be given an `owner` role which cannot
---
# 0.3.3 (October 28, 2024)
- Added the ability to load a `.env` file from the PWD when `LOAD_ENV_FILE=true` is set as an environment variable.
- Fixed an issue where non-English languages could not create Pre-auth keys due to a localization error
- Improved ACL editor performance by switching back to CodeMirror 6
@@ -265,6 +292,7 @@ If there are no users, the first user will be given an `owner` role which cannot
---
# 0.3.2 (October 11, 2024)
- Implement the ability to create and expire pre-auth keys (fixes [#22](https://github.com/tale/headplane/issues/22))
- Fix machine registration not working as expected (fixes [#27](https://github.com/tale/headplane/issues/27))
- Removed more references to usernames in MagicDNS hostnames (fixes [#35](https://github.com/tale/headplane/issues/35))
@@ -274,6 +302,7 @@ If there are no users, the first user will be given an `owner` role which cannot
---
# 0.3.1 (October 3, 2024)
- Fixed the Docker integration to properly support custom socket paths. This regressed at some point previously.
- Allow you to register a machine using machine keys (`nodekey:...`) on the machines page.
- Added the option for debug logs with the `DEBUG=true` environment variable.
@@ -281,12 +310,14 @@ If there are no users, the first user will be given an `owner` role which cannot
---
# 0.3.0 (September 25, 2024)
- Bumped the minimum supported version of Headscale to 0.23.
- Updated the UI to respect `dns.use_username_in_magic_dns`.
---
# 0.2.4 (August 24, 2024)
- Removed ACL management from the integration since Headscale 0.23-beta2 now supports it natively.
- Removed the `ACL_FILE` environment variable since it's no longer needed.
- Introduce a `COOKIE_SECURE=false` environment variable to disable HTTPS requirements for cookies.
@@ -295,6 +326,7 @@ If there are no users, the first user will be given an `owner` role which cannot
---
# 0.2.3 (August 23, 2024)
- Change the minimum required version of Headscale to 0.23-beta2
- Support the new API policy mode for Headscale 0.23-beta1
- Switch to the new DNS configuration in Headscale 0.23-beta2 (fixes [#29](https://github.com/tale/headplane/issues/29))
@@ -303,6 +335,7 @@ If there are no users, the first user will be given an `owner` role which cannot
---
# 0.2.2 (August 2, 2024)
- Added a proper Kubernetes integration which utilizes `shareProcessNamespace` for PIDs.
- Added a new logger utility that shows categories, levels, and timestamps.
- Reimplemented the integration system to be more resilient and log more information.
@@ -311,6 +344,7 @@ If there are no users, the first user will be given an `owner` role which cannot
---
# 0.2.1 (July 7, 2024)
- Added the ability to manage custom DNS records on your Tailnet.
- ACL tags for machines are now able to be changed via the machine menu.
- Fixed a bug where the ACL editor did not show the diffs correctly.
@@ -319,12 +353,14 @@ If there are no users, the first user will be given an `owner` role which cannot
---
# 0.2.0 (June 23, 2024)
- Fix the dropdown options for machines not working on the machines page.
- Add an option to change the machine owner in the dropdown (aside from the users page).
---
# 0.1.9 (June 2, 2024)
- Switch to Monaco editor with proper HuJSON and YAML syntax highlighting.
- Utilize magic DNS hostnames for the machine overview page.
- Fixed the expiry issue once and for all.
@@ -333,6 +369,7 @@ If there are no users, the first user will be given an `owner` role which cannot
---
# 0.1.8 (June 2, 2024)
- Built basic functionality for the machine overview page (by machine ID).
- Possibly fixed an issue where expiry disabled machines' timestamps weren't handled correctly.
- Prevent users from being deleted if they still have ownership of machines.
@@ -341,6 +378,7 @@ If there are no users, the first user will be given an `owner` role which cannot
---
# 0.1.7 (May 30, 2024)
- Added support for the `HEADSCALE_INTEGRATION` variable to allow for advanced integration without Docker.
- Fixed a bug where the `expiry` field on the Headscale configuration could cause crashes.
- Made the strict configuration loader more lenient to allow for more flexibility.
@@ -351,6 +389,7 @@ If there are no users, the first user will be given an `owner` role which cannot
---
# 0.1.6 (May 22, 2024)
- Added experimental support for advanced integration without Docker.
- Fixed a crash where the Docker integration tried to use `process.env.API_KEY` instead of context.
- Fixed a crash where `ROOT_API_KEY` was not respected in the OIDC flow.
@@ -358,6 +397,7 @@ If there are no users, the first user will be given an `owner` role which cannot
---
# 0.1.5 (May 20, 2024)
- Robust configuration handling with fallbacks based on the headscale source.
- Support for `client_secret_path` on configuration file based OIDC.
- `DISABLE_API_KEY_LOGIN` now works as expected (non 'true' values work).
+119 -111
View File
@@ -1,123 +1,131 @@
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';
import { AlertCircle, CloudOff } from "lucide-react";
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#troubleshooting"
>
Learn more
</Link>
</Card.Text>
</Card>
);
import Card from "~/components/Card";
import Code from "~/components/Code";
import Link from "~/components/Link";
import { OidcConnectorError } from "~/server/web/oidc-connector";
export function OidcDiscoveryFailedNotice() {
return (
<Card className="m-4 mb-4 max-w-md border border-yellow-500 sm:m-0 sm:mb-4">
<div className="flex items-center justify-between gap-4">
<Card.Title className="text-yellow-500">SSO Temporarily Unavailable</Card.Title>
<CloudOff className="mb-2 h-6 w-6 text-yellow-500" />
</div>
<Card.Text className="text-sm">
Unable to reach the identity provider. Single Sign-On will be available once the provider is
reachable again. You can still sign in with an API key.
</Card.Text>
</Card>
);
}
export function OidcConfigErrorNotice({ errors }: { errors: OidcConnectorError[] }) {
return (
<Card className="m-4 mb-4 max-w-md border border-red-500 sm:m-0 sm:mb-4">
<div className="flex items-center justify-between gap-4">
<Card.Title className="text-red-500">Authentication Error</Card.Title>
<AlertCircle className="mb-2 h-6 w-6 text-red-500" />
</div>
<Card.Text className="text-sm">
The OpenID Connect (OIDC) Single Sign-On (SSO) configuration has issues:{" "}
<ul className="mt-2 mb-1 list-inside list-disc">
{mapOidcErrorsToMessages(errors).map((code) => (
<li key={code.key}>{code.node}</li>
))}
</ul>{" "}
<Link
name="Headplane OIDC Issues"
to="https://headplane.net/configuration/sso#troubleshooting"
>
Learn more
</Link>
</Card.Text>
</Card>
);
}
function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
const messages: {
key: string;
node: React.ReactNode;
}[] = [];
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;
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_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_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_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 "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;
}
}
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;
return messages;
}
+136 -145
View File
@@ -1,165 +1,156 @@
import { AlertCircle } from 'lucide-react';
import { useEffect, useState } from 'react';
import {
Form,
Link as RemixLink,
redirect,
useSearchParams,
} from 'react-router';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Code from '~/components/Code';
import Input from '~/components/Input';
import Link from '~/components/Link';
import { useLiveData } from '~/utils/live-data';
import type { Route } from './+types/page';
import { loginAction } from './action';
import { OidcConfigErrorNotice } from './config-error';
import Logout from './logout';
import { OidcErrorNotice } from './oidc-error';
import { AlertCircle } from "lucide-react";
import { useEffect, useState } from "react";
import { Form, Link as RemixLink, redirect, useSearchParams } from "react-router";
import Button from "~/components/Button";
import Card from "~/components/Card";
import Code from "~/components/Code";
import Input from "~/components/Input";
import Link from "~/components/Link";
import { useLiveData } from "~/utils/live-data";
import type { Route } from "./+types/page";
import { loginAction } from "./action";
import { OidcConfigErrorNotice, OidcDiscoveryFailedNotice } from "./config-error";
import Logout from "./logout";
import { OidcErrorNotice } from "./oidc-error";
export async function loader({ request, context }: Route.LoaderArgs) {
try {
await context.sessions.auth(request);
return redirect('/machines');
} catch {}
try {
await context.sessions.auth(request);
return redirect("/machines");
} catch {}
const qp = new URL(request.url).searchParams;
const urlState = qp.get('s') ?? undefined;
const qp = new URL(request.url).searchParams;
const urlState = qp.get("s") ?? undefined;
// MARK: This works because the OIDC connector will always return false
// for `isExclusive` if the OIDC config isn't usable.
if (context.oidcConnector?.isExclusive && urlState !== 'logout') {
return redirect('/oidc/start');
}
const oidcConnector = await context.oidcConnector?.get();
const isOidcConnectorEnabled = context.oidcConnector?.isValid;
const oidcErrorCodes = !isOidcConnectorEnabled
? (context.oidcConnector?.errors ?? [])
: [];
// MARK: This works because the OIDC connector will always return false
// for `isExclusive` if the OIDC config isn't usable.
if (oidcConnector?.isExclusive && urlState !== "logout") {
return redirect("/oidc/start");
}
return {
isCookieSecureEnabled: context.config.server.cookie_secure,
isOidcConnectorEnabled,
oidcErrorCodes,
urlState,
};
const isOidcConnectorEnabled = oidcConnector?.isValid;
const oidcErrorCodes = !isOidcConnectorEnabled ? (oidcConnector?.errors ?? []) : [];
return {
isCookieSecureEnabled: context.config.server.cookie_secure,
isOidcConnectorEnabled,
oidcErrorCodes,
urlState,
};
}
export const action = loginAction;
export default function Page({ loaderData, actionData }: Route.ComponentProps) {
const {
isCookieSecureEnabled,
isOidcConnectorEnabled,
oidcErrorCodes,
urlState,
} = loaderData;
const { isCookieSecureEnabled, isOidcConnectorEnabled, oidcErrorCodes, urlState } = loaderData;
const [showCookieWarning, setShowCookieWarning] = useState(false);
const [params] = useSearchParams();
const { pause } = useLiveData();
const [showCookieWarning, setShowCookieWarning] = useState(false);
const [params] = useSearchParams();
const { pause } = useLiveData();
useEffect(() => {
// This page does NOT need stale while revalidate logic
pause();
useEffect(() => {
// This page does NOT need stale while revalidate logic
pause();
if (isCookieSecureEnabled && window.location.protocol !== 'https:') {
setShowCookieWarning(true);
}
});
if (isCookieSecureEnabled && window.location.protocol !== "https:") {
setShowCookieWarning(true);
}
});
useEffect(() => {
// State is a one time thing, we need to remove it after it has
// been consumed to prevent logic loops.
if (urlState !== null) {
const searchParams = new URLSearchParams(params);
searchParams.delete('s');
useEffect(() => {
// State is a one time thing, we need to remove it after it has
// been consumed to prevent logic loops.
if (urlState !== null) {
const searchParams = new URLSearchParams(params);
searchParams.delete("s");
// Replacing because it's not a navigation, just a cleanup of the URL
// We can't use the useSearchParams method since it revalidates
// which will trigger a full reload
const newUrl = searchParams.toString()
? `{${window.location.pathname}?${searchParams.toString()}`
: window.location.pathname;
// Replacing because it's not a navigation, just a cleanup of the URL
// We can't use the useSearchParams method since it revalidates
// which will trigger a full reload
const newUrl = searchParams.toString()
? `{${window.location.pathname}?${searchParams.toString()}`
: window.location.pathname;
window.history.replaceState(null, '', newUrl);
}
}, [urlState, params]);
window.history.replaceState(null, "", newUrl);
}
}, [urlState, params]);
if (urlState === 'logout') {
return <Logout />;
}
if (urlState === "logout") {
return <Logout />;
}
return (
<div className="flex w-screen h-screen items-center justify-center">
<div>
{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">
<div className="flex items-center justify-between gap-4">
<Card.Title className="text-red-500">
Configuration Issue
</Card.Title>
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
</div>
{showCookieWarning ? (
<Card.Text className="text-sm">
Headplane is configured to use secure cookies, but this site is
being served over an insecure connection and login will not work
correctly.{' '}
<Link
name="Headplane Common Issues"
to="https://headplane.net/configuration/common-issues#issue-logging-in-does-not-do-anything"
>
Learn more.
</Link>
</Card.Text>
) : undefined}
</Card>
) : undefined}
<Card className="max-w-md m-4 sm:m-0">
<Card.Title>Welcome to Headplane</Card.Title>
<Form method="POST">
<Card.Text>
Enter an API key to authenticate with Headplane. You can generate
one by running <Code>headscale apikeys create</Code> in your
terminal.
</Card.Text>
<Input
className="mt-8 mb-2"
isRequired
label="API Key"
labelHidden
name="api_key"
placeholder="API Key"
type="password"
/>
{actionData?.success === false ? (
<Card.Text className="text-sm mb-2 text-red-600 dark:text-red-300">
{actionData.message}
</Card.Text>
) : undefined}
<Button className="w-full" type="submit" variant="heavy">
Sign In
</Button>
</Form>
{isOidcConnectorEnabled ? (
<RemixLink to="/oidc/start">
<Button
className="w-full mt-2"
isDisabled={oidcErrorCodes.length > 0}
variant="light"
>
Single Sign-On
</Button>
</RemixLink>
) : undefined}
</Card>
</div>
</div>
);
return (
<div className="flex h-screen w-screen items-center justify-center">
<div>
{urlState?.startsWith("error_") ? (
<OidcErrorNotice code={urlState} />
) : oidcErrorCodes.includes("DISCOVERY_FAILED") ? (
<OidcDiscoveryFailedNotice />
) : oidcErrorCodes.length > 0 ? (
<OidcConfigErrorNotice errors={oidcErrorCodes} />
) : showCookieWarning ? (
<Card className="m-4 mb-4 max-w-md border border-red-500 sm:m-0 sm:mb-4">
<div className="flex items-center justify-between gap-4">
<Card.Title className="text-red-500">Configuration Issue</Card.Title>
<AlertCircle className="mb-2 h-6 w-6 text-red-500" />
</div>
{showCookieWarning ? (
<Card.Text className="text-sm">
Headplane is configured to use secure cookies, but this site is being served over an
insecure connection and login will not work correctly.{" "}
<Link
name="Headplane Common Issues"
to="https://headplane.net/configuration/common-issues#issue-logging-in-does-not-do-anything"
>
Learn more.
</Link>
</Card.Text>
) : undefined}
</Card>
) : undefined}
<Card className="m-4 max-w-md sm:m-0">
<Card.Title>Welcome to Headplane</Card.Title>
<Form method="POST">
<Card.Text>
Enter an API key to authenticate with Headplane. You can generate one by running{" "}
<Code>headscale apikeys create</Code> in your terminal.
</Card.Text>
<Input
className="mt-8 mb-2"
isRequired
label="API Key"
labelHidden
name="api_key"
placeholder="API Key"
type="password"
/>
{actionData?.success === false ? (
<Card.Text className="mb-2 text-sm text-red-600 dark:text-red-300">
{actionData.message}
</Card.Text>
) : undefined}
<Button className="w-full" type="submit" variant="heavy">
Sign In
</Button>
</Form>
{isOidcConnectorEnabled ? (
<RemixLink to="/oidc/start">
<Button
className="mt-2 w-full"
isDisabled={oidcErrorCodes.length > 0}
variant="light"
>
Single Sign-On
</Button>
</RemixLink>
) : undefined}
</Card>
</div>
</div>
);
}
+136 -152
View File
@@ -1,171 +1,155 @@
import { createHash } from 'node:crypto';
import { count, eq } from 'drizzle-orm';
import * as oidc from 'openid-client';
import { data, redirect } from 'react-router';
import { ulid } from 'ulidx';
import { users } from '~/server/db/schema';
import { Roles } from '~/server/web/roles';
import log from '~/utils/log';
import { createOidcStateCookie } from '~/utils/oidc-state';
import type { Route } from './+types/oidc-callback';
import { count, eq } from "drizzle-orm";
import { createHash } from "node:crypto";
import * as oidc from "openid-client";
import { data, redirect } from "react-router";
import { ulid } from "ulidx";
import { users } from "~/server/db/schema";
import { Roles } from "~/server/web/roles";
import log from "~/utils/log";
import { createOidcStateCookie } from "~/utils/oidc-state";
import type { Route } from "./+types/oidc-callback";
export async function loader({ request, context }: Route.LoaderArgs) {
if (!context.oidcConnector?.isValid) {
throw data('OIDC is not enabled or misconfigured', { status: 501 });
}
const oidcConnector = await context.oidcConnector?.get();
if (!oidcConnector?.isValid) {
throw data("OIDC is not enabled or misconfigured", { status: 501 });
}
const url = new URL(request.url);
if (url.searchParams.toString().length === 0) {
return redirect('/login?s=error_no_query');
}
const url = new URL(request.url);
if (url.searchParams.toString().length === 0) {
return redirect("/login?s=error_no_query");
}
const cookie = createOidcStateCookie(context.config);
const oidcCookieState = await cookie.parse(request.headers.get('Cookie'));
const cookie = createOidcStateCookie(context.config);
const oidcCookieState = await cookie.parse(request.headers.get("Cookie"));
if (oidcCookieState == null) {
log.warn('auth', 'Called OIDC callback without session cookie');
return redirect('/login?s=error_no_session');
}
if (oidcCookieState == null) {
log.warn("auth", "Called OIDC callback without session cookie");
return redirect("/login?s=error_no_session");
}
const { state, nonce, redirect_uri, verifier } = oidcCookieState;
if (!state || !nonce || !redirect_uri || !verifier) {
log.warn('auth', 'OIDC session cookie is missing required fields');
return redirect('/login?s=error_invalid_session');
}
const { state, nonce, redirect_uri, verifier } = oidcCookieState;
if (!state || !nonce || !redirect_uri || !verifier) {
log.warn("auth", "OIDC session cookie is missing required fields");
return redirect("/login?s=error_invalid_session");
}
try {
const callbackUrl = new URL(redirect_uri);
const currentUrl = new URL(request.url);
callbackUrl.search = currentUrl.search;
try {
const callbackUrl = new URL(redirect_uri);
const currentUrl = new URL(request.url);
callbackUrl.search = currentUrl.search;
const tokens = await oidc.authorizationCodeGrant(
context.oidcConnector.client,
callbackUrl,
{
expectedState: state,
expectedNonce: nonce,
...(context.oidcConnector.usePKCE
? { pkceCodeVerifier: verifier }
: {}),
},
);
const tokens = await oidc.authorizationCodeGrant(oidcConnector.client, callbackUrl, {
expectedState: state,
expectedNonce: nonce,
...(oidcConnector.usePKCE ? { pkceCodeVerifier: verifier } : {}),
});
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 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,
);
const userInfo = await oidc.fetchUserInfo(
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'));
// 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 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 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
.select({ count: count() })
.from(users)
.where(eq(users.caps, Roles.owner));
const [{ count: userCount }] = await context.db
.select({ count: count() })
.from(users)
.where(eq(users.caps, Roles.owner));
await context.db
.insert(users)
.values({
id: ulid(),
sub: claims.sub,
caps: userCount === 0 ? Roles.owner : Roles.member,
})
.onConflictDoNothing();
await context.db
.insert(users)
.values({
id: ulid(),
sub: claims.sub,
caps: userCount === 0 ? Roles.owner : Roles.member,
})
.onConflictDoNothing();
return redirect('/', {
headers: {
'Set-Cookie': await context.sessions.createSession({
api_key: context.oidcConnector.apiKey,
user: {
subject: claims.sub,
username,
name,
email: userInfo.email,
picture,
},
}),
},
});
} catch (error) {
if (error instanceof oidc.ResponseBodyError) {
log.error(
'auth',
'Got an OIDC response error body: %s',
JSON.stringify(error.cause),
);
return redirect("/", {
headers: {
"Set-Cookie": await context.sessions.createSession({
api_key: oidcConnector.apiKey,
user: {
subject: claims.sub,
username,
name,
email: userInfo.email,
picture,
},
}),
},
});
} catch (error) {
if (error instanceof oidc.ResponseBodyError) {
log.error("auth", "Got an OIDC response error body: %s", JSON.stringify(error.cause));
// Check for PKCE-related errors
if (
error.error.toLowerCase().includes('code_verifier') ||
error.error.toLowerCase().includes('code verifier') ||
error.error.toLowerCase().includes('pkce')
) {
log.error(
'auth',
'PKCE error detected. Your OIDC provider may require PKCE to be enabled. Current setting: use_pkce=%s',
context.oidcConnector?.usePKCE,
);
// Check for PKCE-related errors
if (
error.error.toLowerCase().includes("code_verifier") ||
error.error.toLowerCase().includes("code verifier") ||
error.error.toLowerCase().includes("pkce")
) {
log.error(
"auth",
"PKCE error detected. Your OIDC provider may require PKCE to be enabled. Current setting: use_pkce=%s",
oidcConnector.usePKCE,
);
if (!context.oidcConnector?.usePKCE) {
log.error(
'auth',
'Consider setting oidc.use_pkce=true in your configuration if your provider requires PKCE',
);
}
}
} else if (error instanceof oidc.AuthorizationResponseError) {
log.error(
'auth',
'Got an OIDC authorization response error: %s',
error.error,
);
} else if (error instanceof oidc.WWWAuthenticateChallengeError) {
log.error('auth', 'Got an OIDC WWW-Authenticate challenge error');
} else if (error instanceof oidc.ClientError) {
log.error(
'auth',
'Got an OIDC authorization client error: %s',
error.cause instanceof Error
? error.cause.message
: String(error.cause),
);
} else {
log.error(
'auth',
'Got an OIDC error: %s',
error instanceof Error && error.cause
? JSON.stringify(error.cause)
: String(error),
);
}
return redirect('/login?s=error_auth_failed');
}
if (!oidcConnector.usePKCE) {
log.error(
"auth",
"Consider setting oidc.use_pkce=true in your configuration if your provider requires PKCE",
);
}
}
} else if (error instanceof oidc.AuthorizationResponseError) {
log.error("auth", "Got an OIDC authorization response error: %s", error.error);
} else if (error instanceof oidc.WWWAuthenticateChallengeError) {
log.error("auth", "Got an OIDC WWW-Authenticate challenge error");
} else if (error instanceof oidc.ClientError) {
log.error(
"auth",
"Got an OIDC authorization client error: %s",
error.cause instanceof Error ? error.cause.message : String(error.cause),
);
} else {
log.error(
"auth",
"Got an OIDC error: %s",
error instanceof Error && error.cause ? JSON.stringify(error.cause) : String(error),
);
}
return redirect("/login?s=error_auth_failed");
}
}
+66 -69
View File
@@ -1,84 +1,81 @@
import * as oidc from 'openid-client';
import { data, redirect } from 'react-router';
import { HeadplaneConfig } from '~/server/config/config-schema';
import { createOidcStateCookie } from '~/utils/oidc-state';
import type { Route } from './+types/oidc-start';
import * as oidc from "openid-client";
import { data, redirect } from "react-router";
import { HeadplaneConfig } from "~/server/config/config-schema";
import { createOidcStateCookie } from "~/utils/oidc-state";
import type { Route } from "./+types/oidc-start";
export async function loader({ request, context }: Route.LoaderArgs) {
try {
await context.sessions.auth(request);
return redirect('/');
} catch {}
try {
await context.sessions.auth(request);
return redirect("/");
} catch {}
if (!context.oidcConnector?.isValid) {
throw data('OIDC is not enabled or misconfigured', { status: 501 });
}
const oidcConnector = await context.oidcConnector?.get();
if (!oidcConnector?.isValid) {
throw data("OIDC is not enabled or misconfigured", { status: 501 });
}
const cookie = createOidcStateCookie(context.config);
const redirect_uri = getRedirectUri(context.config, request);
const cookie = createOidcStateCookie(context.config);
const redirect_uri = getRedirectUri(context.config, request);
const nonce = oidc.randomNonce();
const verifier = oidc.randomPKCECodeVerifier();
const state = oidc.randomState();
const nonce = oidc.randomNonce();
const verifier = oidc.randomPKCECodeVerifier();
const state = oidc.randomState();
const url = oidc.buildAuthorizationUrl(context.oidcConnector.client, {
...(context.oidcConnector.extraParams ?? {}),
scope: context.oidcConnector.scope,
redirect_uri,
state,
nonce,
...(context.oidcConnector.usePKCE
? {
code_challenge_method: 'S256',
code_challenge: await oidc.calculatePKCECodeChallenge(verifier),
}
: {}),
});
const url = oidc.buildAuthorizationUrl(oidcConnector.client, {
...oidcConnector.extraParams,
scope: oidcConnector.scope,
redirect_uri,
state,
nonce,
...(oidcConnector.usePKCE
? {
code_challenge_method: "S256",
code_challenge: await oidc.calculatePKCECodeChallenge(verifier),
}
: {}),
});
return redirect(url.href, {
status: 302,
headers: {
'Set-Cookie': await cookie.serialize({
state,
nonce,
verifier,
redirect_uri,
}),
},
});
return redirect(url.href, {
status: 302,
headers: {
"Set-Cookie": await cookie.serialize({
state,
nonce,
verifier,
redirect_uri,
}),
},
});
}
function getRedirectUri(config: HeadplaneConfig, req: Request): string {
if (config.server.base_url != null) {
const url = new URL(`${__PREFIX__}/oidc/callback`, config.server.base_url);
return url.href;
}
if (config.server.base_url != null) {
const url = new URL(`${__PREFIX__}/oidc/callback`, config.server.base_url);
return url.href;
}
if (config.oidc?.redirect_uri != null) {
const url = new URL(
`${__PREFIX__}/oidc/callback`,
config.oidc.redirect_uri,
);
return url.href;
}
if (config.oidc?.redirect_uri != null) {
const url = new URL(`${__PREFIX__}/oidc/callback`, config.oidc.redirect_uri);
return url.href;
}
const url = new URL(`${__PREFIX__}/oidc/callback`, req.url);
let host = req.headers.get('Host');
if (!host) {
host = req.headers.get('X-Forwarded-Host');
}
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,
},
);
}
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;
const proto = req.headers.get("X-Forwarded-Proto");
url.protocol = proto ?? "http:";
url.host = host;
return url.href;
}
+68 -71
View File
@@ -1,76 +1,73 @@
import { ArrowRight } from 'lucide-react';
import { Link as RemixLink } from 'react-router';
import Link from '~/components/Link';
import type { Route } from './+types/overview';
import { ArrowRight } from "lucide-react";
import { Link as RemixLink } from "react-router";
import Link from "~/components/Link";
import type { Route } from "./+types/overview";
export async function loader({ context }: Route.LoaderArgs) {
return {
config: context.hs.writable(),
isOidcEnabled: context.oidcConnector?.isValid ?? false,
};
const oidcConnector = await context.oidcConnector?.get();
return {
config: context.hs.writable(),
isOidcEnabled: oidcConnector?.isValid ?? false,
};
}
export default function Page({
loaderData: { config, isOidcEnabled },
}: Route.ComponentProps) {
return (
<div className="flex flex-col gap-8 max-w-(--breakpoint-lg)">
<div className="flex flex-col w-full sm:w-2/3">
<h1 className="text-2xl font-medium mb-4">Settings</h1>
<p>
The settings page is still under construction. As I'm able to add more
features, I'll be adding them here. If you require any features, feel
free to open an issue on the GitHub repository.
</p>
</div>
<div className="flex flex-col w-full sm:w-2/3">
<h1 className="text-2xl font-medium mb-4">Pre-Auth Keys</h1>
<p>
Headscale fully supports pre-authentication keys in order to easily
add devices to your Tailnet. To learn more about using
pre-authentication keys, visit the{' '}
<Link
name="Tailscale Auth Keys documentation"
to="https://tailscale.com/kb/1085/auth-keys/"
>
Tailscale documentation
</Link>
</p>
</div>
<RemixLink to="/settings/auth-keys">
<div className="text-lg font-medium flex items-center">
Manage Auth Keys
<ArrowRight className="w-5 h-5 ml-2" />
</div>
</RemixLink>
{config && isOidcEnabled ? (
<>
<div className="flex flex-col w-full sm:w-2/3">
<h1 className="text-2xl font-medium mb-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>
<RemixLink to="/settings/restrictions">
<div className="text-lg font-medium flex items-center">
Manage Restrictions
<ArrowRight className="w-5 h-5 ml-2" />
</div>
</RemixLink>
</>
) : undefined}
</div>
);
export default function Page({ loaderData: { config, isOidcEnabled } }: Route.ComponentProps) {
return (
<div className="flex max-w-(--breakpoint-lg) flex-col gap-8">
<div className="flex w-full flex-col sm:w-2/3">
<h1 className="mb-4 text-2xl font-medium">Settings</h1>
<p>
The settings page is still under construction. As I'm able to add more features, I'll be
adding them here. If you require any features, feel free to open an issue on the GitHub
repository.
</p>
</div>
<div className="flex w-full flex-col sm:w-2/3">
<h1 className="mb-4 text-2xl font-medium">Pre-Auth Keys</h1>
<p>
Headscale fully supports pre-authentication keys in order to easily add devices to your
Tailnet. To learn more about using pre-authentication keys, visit the{" "}
<Link
name="Tailscale Auth Keys documentation"
to="https://tailscale.com/kb/1085/auth-keys/"
>
Tailscale documentation
</Link>
</p>
</div>
<RemixLink to="/settings/auth-keys">
<div className="flex items-center text-lg font-medium">
Manage Auth Keys
<ArrowRight className="ml-2 h-5 w-5" />
</div>
</RemixLink>
{config && isOidcEnabled ? (
<>
<div className="flex w-full flex-col sm:w-2/3">
<h1 className="mb-4 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>
<RemixLink to="/settings/restrictions">
<div className="flex items-center text-lg font-medium">
Manage Restrictions
<ArrowRight className="ml-2 h-5 w-5" />
</div>
</RemixLink>
</>
) : undefined}
</div>
);
}
+98 -103
View File
@@ -1,142 +1,137 @@
import { join } from 'node:path';
import { exit, versions } from 'node:process';
import { createHonoServer } from 'react-router-hono-server/node';
import log from '~/utils/log';
import { loadIntegration } from './config/integration';
import { loadConfig } from './config/load';
import { createDbClient } from './db/client.server';
import { createHeadscaleInterface } from './headscale/api';
import { loadHeadscaleConfig } from './headscale/config-loader';
import { createHeadplaneAgent } from './hp-agent';
import { createSessionStorage } from './web/sessions';
import { join } from "node:path";
import { exit, versions } from "node:process";
import { createHonoServer } from "react-router-hono-server/node";
import log from "~/utils/log";
import { loadIntegration } from "./config/integration";
import { loadConfig } from "./config/load";
import { createDbClient } from "./db/client.server";
import { createHeadscaleInterface } from "./headscale/api";
import { loadHeadscaleConfig } from "./headscale/config-loader";
import { createHeadplaneAgent } from "./hp-agent";
import { createSessionStorage } from "./web/sessions";
declare global {
const __PREFIX__: string;
const __VERSION__: string;
const __PREFIX__: string;
const __VERSION__: string;
}
// MARK: Side-Effects
// This module contains a side-effect because everything running here
// exists for the lifetime of the process, making it appropriate.
log.info('server', 'Running Node.js %s', versions.node);
log.info("server", "Running Node.js %s", versions.node);
let config: HeadplaneConfig;
try {
config = await loadConfig();
config = await loadConfig();
} catch (error) {
if (error instanceof ConfigError) {
log.error('server', 'Unable to load configuration: %s', error.message);
}
if (error instanceof ConfigError) {
log.error("server", "Unable to load configuration: %s", error.message);
}
exit(1);
exit(1);
}
const db = await createDbClient(join(config.server.data_path, 'hp_persist.db'));
const agents = await createHeadplaneAgent(
config.integration?.agent,
config.headscale.url,
db,
);
const db = await createDbClient(join(config.server.data_path, "hp_persist.db"));
const agents = await createHeadplaneAgent(config.integration?.agent, config.headscale.url, db);
const hsApi = await createHeadscaleInterface(
config.headscale.url,
config.headscale.tls_cert_path,
);
const hsApi = await createHeadscaleInterface(config.headscale.url, config.headscale.tls_cert_path);
// We also use this file to load anything needed by the react router code.
// These are usually per-request things that we need access to, like the
// helper that can issue and revoke cookies.
export type LoadContext = typeof appLoadContext;
import 'react-router';
import { HeadplaneConfig } from './config/config-schema';
import { ConfigError } from './config/error';
import { createOidcConnector } from './web/oidc-connector';
import "react-router";
import { HeadplaneConfig } from "./config/config-schema";
import { ConfigError } from "./config/error";
import { createLazyOidcConnector } from "./web/oidc-connector";
declare module 'react-router' {
interface AppLoadContext extends LoadContext {}
declare module "react-router" {
interface AppLoadContext extends LoadContext {}
}
const appLoadContext = {
config,
hs: await loadHeadscaleConfig(
config.headscale.config_path,
config.headscale.config_strict,
config.headscale.dns_records_path,
),
config,
hs: await loadHeadscaleConfig(
config.headscale.config_path,
config.headscale.config_strict,
config.headscale.dns_records_path,
),
// TODO: Better cookie options in config
sessions: await createSessionStorage({
secret: config.server.cookie_secret,
db,
oidcUsersFile: config.oidc?.user_storage_file,
cookie: {
name: '_hp_auth',
secure: config.server.cookie_secure,
maxAge: config.server.cookie_max_age,
domain: config.server.cookie_domain,
},
}),
// TODO: Better cookie options in config
sessions: await createSessionStorage({
secret: config.server.cookie_secret,
db,
oidcUsersFile: config.oidc?.user_storage_file,
cookie: {
name: "_hp_auth",
secure: config.server.cookie_secure,
maxAge: config.server.cookie_max_age,
domain: config.server.cookie_domain,
},
}),
hsApi,
agents,
integration: await loadIntegration(config.integration),
oidcConnector: config.oidc
? await createOidcConnector(
config.server.base_url,
config.oidc,
hsApi.getRuntimeClient(config.oidc.headscale_api_key),
)
: undefined,
db,
hsApi,
agents,
integration: await loadIntegration(config.integration),
oidcConnector: config.oidc
? createLazyOidcConnector(
config.server.base_url,
config.oidc,
hsApi.getRuntimeClient(config.oidc.headscale_api_key),
)
: undefined,
db,
};
declare module 'react-router' {
interface AppLoadContext extends LoadContext {}
declare module "react-router" {
interface AppLoadContext extends LoadContext {}
}
export default createHonoServer({
overrideGlobalObjects: true,
port: config.server.port,
hostname: config.server.host,
beforeAll: async (app) => {
app.use(__PREFIX__, async (c) => {
return c.redirect(`${__PREFIX__}/`);
});
},
serveStaticOptions: {
publicAssets: {
// This is part of our monkey-patch for react-router-hono-server
// To see the first part, go to the patches/ directory.
rewriteRequestPath: (path) => path.replace(`${__PREFIX__}`, ''),
},
clientAssets: {
// This is part of our monkey-patch for react-router-hono-server
// To see the first part, go to the patches/ directory.
rewriteRequestPath: (path) => path.replace(`${__PREFIX__}`, ''),
},
},
overrideGlobalObjects: true,
port: config.server.port,
hostname: config.server.host,
beforeAll: async (app) => {
app.use(__PREFIX__, async (c) => {
return c.redirect(`${__PREFIX__}/`);
});
},
serveStaticOptions: {
publicAssets: {
// This is part of our monkey-patch for react-router-hono-server
// To see the first part, go to the patches/ directory.
rewriteRequestPath: (path) => path.replace(`${__PREFIX__}`, ""),
},
clientAssets: {
// This is part of our monkey-patch for react-router-hono-server
// To see the first part, go to the patches/ directory.
rewriteRequestPath: (path) => path.replace(`${__PREFIX__}`, ""),
},
},
// Only log in development mode
defaultLogger: import.meta.env.DEV,
getLoadContext() {
// TODO: This is the place where we can handle reverse proxy translation
// This is better than doing it in the OIDC client, since we can do it
// for all requests, not just OIDC ones.
return appLoadContext;
},
// Only log in development mode
defaultLogger: import.meta.env.DEV,
getLoadContext() {
// TODO: This is the place where we can handle reverse proxy translation
// This is better than doing it in the OIDC client, since we can do it
// for all requests, not just OIDC ones.
return appLoadContext;
},
listeningListener(info) {
log.info('server', 'Running on %s:%s', info.address, info.port);
},
listeningListener(info) {
log.info("server", "Running on %s:%s", info.address, info.port);
},
});
process.on('SIGINT', () => {
log.info('server', 'Received SIGINT, shutting down...');
process.exit(0);
process.on("SIGINT", () => {
log.info("server", "Received SIGINT, shutting down...");
process.exit(0);
});
process.on('SIGTERM', () => {
log.info('server', 'Received SIGTERM, shutting down...');
process.exit(0);
process.on("SIGTERM", () => {
log.info("server", "Received SIGTERM, shutting down...");
process.exit(0);
});
+255 -193
View File
@@ -1,41 +1,116 @@
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';
import * as oidc from "openid-client";
export type OidcConfig = NonNullable<HeadplaneConfig['oidc']>;
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';
| "INVALID_API_KEY"
| "MISSING_AUTHORIZATION_ENDPOINT"
| "MISSING_TOKEN_ENDPOINT"
| "MISSING_USERINFO_ENDPOINT"
| "MISSING_REQUIRED_CLAIMS"
| "DISCOVERY_FAILED"
| "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;
usePKCE: boolean;
client: oidc.Configuration;
apiKey: string;
scope: string;
extraParams?: Record<string, string>;
}
| {
isValid: false;
isExclusive: false;
errors: OidcConnectorError[];
};
| {
isValid: true;
isExclusive: boolean;
usePKCE: boolean;
client: oidc.Configuration;
apiKey: string;
scope: string;
extraParams?: Record<string, string>;
}
| {
isValid: false;
isExclusive: false;
errors: OidcConnectorError[];
};
/**
* A lazy OIDC connector that retries initialization on failure.
* This allows OIDC to recover from transient startup failures (e.g., network issues,
* OIDC provider temporarily unavailable) without requiring a server restart.
*/
export interface LazyOidcConnector {
/**
* Get the current OIDC connector state.
* If a previous attempt failed, this will retry initialization.
* Successful results are cached until invalidated.
*/
get(): Promise<OidcConnector>;
/**
* Force a re-initialization of the OIDC connector on the next get() call.
* Useful for manually triggering a retry after configuration changes.
*/
invalidate(): void;
}
/**
* Creates a lazy OIDC connector that retries on failure.
* Successful initialization is cached; failed attempts are retried on each get() call.
*
* @param baseUrl The base URL of the Headplane server.
* @param config The OIDC configuration.
* @param client The Headscale runtime API client.
* @returns A lazy OIDC connector that retries on failure.
*/
export function createLazyOidcConnector(
baseUrl: string | undefined,
config: OidcConfig,
client: RuntimeApiClient,
): LazyOidcConnector {
let cachedConnector: OidcConnector | undefined;
let initPromise: Promise<OidcConnector> | undefined;
return {
async get(): Promise<OidcConnector> {
if (cachedConnector?.isValid) {
return cachedConnector;
}
if (initPromise) {
return initPromise;
}
initPromise = createOidcConnector(baseUrl, config, client);
try {
const connector = await initPromise;
if (connector.isValid) {
cachedConnector = connector;
log.info("auth", "OIDC connector initialized successfully");
} else {
log.warn("auth", "OIDC connector initialization failed, will retry on next request");
}
return connector;
} finally {
// Clear the promise so we can retry on next call if it failed
initPromise = undefined;
}
},
invalidate(): void {
cachedConnector = undefined;
initPromise = undefined;
log.info("auth", "OIDC connector cache invalidated");
},
};
}
/**
* Creates an OIDC connector based on the configuration and Headscale API.
@@ -46,63 +121,63 @@ export type OidcConnector =
* @param client The Headscale runtime API client.
* @returns An OIDC connector with validation status.
*/
export async function createOidcConnector(
baseUrl: string | undefined,
config: OidcConfig,
client: RuntimeApiClient,
async function createOidcConnector(
baseUrl: string | undefined,
config: OidcConfig,
client: RuntimeApiClient,
): Promise<OidcConnector> {
if (baseUrl == null && config.redirect_uri == null) {
log.warn(
'config',
'OIDC is enabled but `server.base_url` is not set in the config. Starting in Headplane 0.7.0 this will be required for OIDC to function properly and will throw errors if not set, see https://headplane.net/features/sso#configuring-oidc for more information.',
);
}
if (baseUrl == null && config.redirect_uri == null) {
log.warn(
"config",
"OIDC is enabled but `server.base_url` is not set in the config. Starting in Headplane 0.7.0 this will be required for OIDC to function properly and will throw errors if not set, see https://headplane.net/features/sso#configuring-oidc for more information.",
);
}
const errors: OidcConnectorError[] = [];
if (!config.headscale_api_key) {
errors.push('INVALID_API_KEY');
return {
isValid: false,
isExclusive: false,
errors,
};
}
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,
};
}
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
}
// 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,
};
}
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,
usePKCE: config.use_pkce,
client: oidcClientOrErrors,
apiKey: config.headscale_api_key,
scope: config.scope,
extraParams: config.extra_params,
};
return {
isValid: true,
isExclusive: config.disable_api_key_login,
usePKCE: config.use_pkce,
client: oidcClientOrErrors,
apiKey: config.headscale_api_key,
scope: config.scope,
extraParams: config.extra_params,
};
}
/**
@@ -113,105 +188,95 @@ export async function createOidcConnector(
* @returns The coalesced OIDC configuration or an array of errors.
*/
async function discoveryCoalesce(
config: OidcConfig,
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 (config.use_pkce === true && !client.serverMetadata().supportsPKCE()) {
log.warn(
'config',
'OIDC provider does not support PKCE, but it is enabled in the config',
);
}
let metadata: oidc.ServerMetadata;
let discoveryFailed = false;
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'];
}
try {
const client = await oidc.discovery(new URL(config.issuer), config.client_id);
metadata = client.serverMetadata();
if (config.use_pkce === true && !client.serverMetadata().supportsPKCE()) {
log.warn("config", "OIDC provider does not support PKCE, but it is enabled in the config");
}
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 != 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('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,
};
}
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",
);
}
}
const errors: OidcConnectorError[] = [];
const authorization_endpoint =
config.authorization_endpoint ?? metadata.authorization_endpoint;
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.warn("oidc", "Failed to reach OIDC provider for discovery, will retry on next request");
discoveryFailed = true;
metadata = {
issuer: config.issuer,
};
}
const token_endpoint = config.token_endpoint ?? metadata.token_endpoint;
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;
const userinfo_endpoint =
config.userinfo_endpoint ?? metadata.userinfo_endpoint;
const hasMissingEndpoints = !authorization_endpoint || !token_endpoint || !userinfo_endpoint;
if (!authorization_endpoint) {
errors.push('MISSING_AUTHORIZATION_ENDPOINT');
}
if (discoveryFailed && hasMissingEndpoints) {
return ["DISCOVERY_FAILED"];
}
if (!token_endpoint) {
errors.push('MISSING_TOKEN_ENDPOINT');
}
const errors: OidcConnectorError[] = [];
if (!userinfo_endpoint) {
errors.push('MISSING_USERINFO_ENDPOINT');
}
if (!authorization_endpoint) {
errors.push("MISSING_AUTHORIZATION_ENDPOINT");
}
if (errors.length > 0) {
return errors;
}
if (!token_endpoint) {
errors.push("MISSING_TOKEN_ENDPOINT");
}
const oidcClient = new oidc.Configuration(
{
...metadata,
issuer: config.issuer,
authorization_endpoint,
token_endpoint,
userinfo_endpoint,
},
config.client_id,
config.client_secret,
negotiateTokenEndpointAuthMethod(config, metadata),
);
if (!userinfo_endpoint) {
errors.push("MISSING_USERINFO_ENDPOINT");
}
return oidcClient;
if (errors.length > 0) {
return errors;
}
const oidcClient = new oidc.Configuration(
{
...metadata,
issuer: config.issuer,
authorization_endpoint,
token_endpoint,
userinfo_endpoint,
},
config.client_id,
config.client_secret,
negotiateTokenEndpointAuthMethod(config, metadata),
);
return oidcClient;
}
/**
@@ -222,39 +287,36 @@ async function discoveryCoalesce(
* @returns The client authentication method for the token endpoint.
*/
function negotiateTokenEndpointAuthMethod(
config: OidcConfig,
metadata: oidc.ServerMetadata,
config: OidcConfig,
metadata: oidc.ServerMetadata,
): oidc.ClientAuth {
if (config.token_endpoint_auth_method != null) {
switch (config.token_endpoint_auth_method) {
case 'client_secret_basic':
return oidc.ClientSecretBasic(config.client_secret);
case 'client_secret_post':
return oidc.ClientSecretPost(config.client_secret);
case 'client_secret_jwt':
return oidc.ClientSecretJwt(config.client_secret);
}
}
if (config.token_endpoint_auth_method != null) {
switch (config.token_endpoint_auth_method) {
case "client_secret_basic":
return oidc.ClientSecretBasic(config.client_secret);
case "client_secret_post":
return oidc.ClientSecretPost(config.client_secret);
case "client_secret_jwt":
return oidc.ClientSecretJwt(config.client_secret);
}
}
const supported = metadata.token_endpoint_auth_methods_supported;
if (supported != null && supported.length > 0) {
// Prefer client_secret_basic (spec default), otherwise use first available
if (supported.includes('client_secret_basic')) {
return oidc.ClientSecretBasic(config.client_secret);
}
const supported = metadata.token_endpoint_auth_methods_supported;
if (supported != null && supported.length > 0) {
// Prefer client_secret_basic (spec default), otherwise use first available
if (supported.includes("client_secret_basic")) {
return oidc.ClientSecretBasic(config.client_secret);
}
if (supported.includes('client_secret_post')) {
return oidc.ClientSecretPost(config.client_secret);
}
if (supported.includes("client_secret_post")) {
return oidc.ClientSecretPost(config.client_secret);
}
if (supported.includes('client_secret_jwt')) {
return oidc.ClientSecretJwt(config.client_secret);
}
}
if (supported.includes("client_secret_jwt")) {
return oidc.ClientSecretJwt(config.client_secret);
}
}
log.warn(
'config',
'Falling back to client_secret_post for token endpoint authentication',
);
return oidc.ClientSecretPost(config.client_secret);
log.warn("config", "Falling back to client_secret_post for token endpoint authentication");
return oidc.ClientSecretPost(config.client_secret);
}
+39 -40
View File
@@ -1,49 +1,48 @@
import { createCookie } from 'react-router';
import type { HeadplaneConfig } from '~/server/config/config-schema';
import { createCookie } from "react-router";
import type { HeadplaneConfig } from "~/server/config/config-schema";
export interface OidcStateCookie {
nonce: string;
state: string;
verifier: string;
redirect_uri: string;
nonce: string;
state: string;
verifier: string;
redirect_uri: string;
}
export function createOidcStateCookie(config: HeadplaneConfig) {
const cookie = createCookie('__oidc_state', {
httpOnly: true,
maxAge: 1800,
secure: config.server.cookie_secure,
domain: config.server.cookie_domain,
path: `${__PREFIX__}/oidc/callback`,
});
const cookie = createCookie("__oidc_state", {
httpOnly: true,
maxAge: 1800,
secure: config.server.cookie_secure,
domain: config.server.cookie_domain,
path: `${__PREFIX__}/oidc/callback`,
});
return {
...cookie,
serialize: async (value: OidcStateCookie): Promise<string> => {
return cookie.serialize(value);
},
return {
...cookie,
serialize: async (value: OidcStateCookie): Promise<string> => {
return cookie.serialize(value);
},
parse: async (
cookieHeader: string | null,
): Promise<OidcStateCookie | null> => {
const parsed = await cookie.parse(cookieHeader);
if (
parsed == null ||
typeof parsed !== 'object' ||
typeof parsed.nonce !== 'string' ||
typeof parsed.state !== 'string' ||
typeof parsed.verifier !== 'string' ||
typeof parsed.redirect_uri !== 'string'
) {
return null;
}
parse: async (cookieHeader: string | null): Promise<OidcStateCookie | null> => {
const parsed = await cookie.parse(cookieHeader);
if (
parsed == null ||
typeof parsed !== "object" ||
typeof parsed.nonce !== "string" ||
typeof parsed.state !== "string" ||
typeof parsed.verifier !== "string" ||
typeof parsed.redirect_uri !== "string"
) {
return null;
}
return {
nonce: parsed.nonce,
state: parsed.state,
verifier: parsed.verifier,
redirect_uri: parsed.redirect_uri,
};
},
};
return {
nonce: parsed.nonce,
state: parsed.state,
verifier: parsed.verifier,
redirect_uri: parsed.redirect_uri,
};
},
};
}