Compare commits

..

4 Commits

Author SHA1 Message Date
Aarnav Tale 3a3e5ca65e feat: pkce 2025-12-04 11:37:22 -05:00
Aarnav Tale 3eff763436 docs: create initial sso page 2025-12-04 10:52:16 -05:00
Aarnav Tale 6ad0847653 fix: the login page would crash if OIDC is not enabled correctly 2025-12-04 10:39:24 -05:00
Aarnav Tale 199ef46ee1 fix: correctly passthrough and handle a new OIDC redirect_uri system 2025-12-04 10:34:48 -05:00
12 changed files with 263 additions and 70 deletions
+2
View File
@@ -10,6 +10,8 @@
- 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.
- 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)).
+1 -1
View File
@@ -24,7 +24,7 @@ export function OidcConfigErrorNotice({
</ul>{' '}
<Link
name="Headplane OIDC Issues"
to="https://headplane.net/configuration/sso#help"
to="https://headplane.net/configuration/sso#troubleshooting"
>
Learn more
</Link>
+1 -1
View File
@@ -47,7 +47,7 @@ function getErrorMessage(code: string) {
case 'error_auth_failed':
return (
<Card.Text>
Authentication with the SSO provider failed. Please tray again later.
Authentication with the SSO provider failed. Please try again later.
Headplane logs may provide more information.
</Card.Text>
);
+1 -3
View File
@@ -1,7 +1,6 @@
import { AlertCircle } from 'lucide-react';
import { useEffect, useState } from 'react';
import {
data,
Form,
Link as RemixLink,
redirect,
@@ -12,7 +11,6 @@ import Card from '~/components/Card';
import Code from '~/components/Code';
import Input from '~/components/Input';
import Link from '~/components/Link';
import { OidcConnectorError } from '~/server/web/oidc-connector';
import { useLiveData } from '~/utils/live-data';
import type { Route } from './+types/page';
import { loginAction } from './action';
@@ -37,7 +35,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const isOidcConnectorEnabled = context.oidcConnector?.isValid;
const oidcErrorCodes = !isOidcConnectorEnabled
? context.oidcConnector!.errors
? (context.oidcConnector?.errors ?? [])
: [];
return {
+17 -26
View File
@@ -1,23 +1,15 @@
import { createHash } from 'node:crypto';
import { count, eq } from 'drizzle-orm';
import * as oidc from 'openid-client';
import {
createCookie,
data,
type LoaderFunctionArgs,
redirect,
} from 'react-router';
import { data, redirect } from 'react-router';
import { ulid } from 'ulidx';
import type { LoadContext } from '~/server';
import { users } from '~/server/db/schema';
import { Roles } from '~/server/web/roles';
import log from '~/utils/log';
import type { OidcCookieState } from './oidc-start';
import { createOidcStateCookie } from '~/utils/oidc-state';
import type { Route } from './+types/oidc-callback';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
export async function loader({ request, context }: Route.LoaderArgs) {
if (!context.oidcConnector?.isValid) {
throw data('OIDC is not enabled or misconfigured', { status: 501 });
}
@@ -27,35 +19,34 @@ export async function loader({
return redirect('/login?s=error_no_query');
}
const cookie = createCookie('__oidc_auth_flow', {
httpOnly: true,
maxAge: 300,
secure: context.config.server.cookie_secure,
domain: context.config.server.cookie_domain,
});
const cookie = createOidcStateCookie(context.config);
const oidcCookieState = await cookie.parse(request.headers.get('Cookie'));
const oidcCookieState: OidcCookieState | null = await cookie.parse(
request.headers.get('Cookie'),
);
if (oidcCookieState == null || typeof oidcCookieState !== 'object') {
if (oidcCookieState == null) {
log.warn('auth', 'Called OIDC callback without session cookie');
return redirect('/login?s=error_no_session');
}
const { state, nonce } = oidcCookieState;
if (!state || !nonce) {
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;
const tokens = await oidc.authorizationCodeGrant(
context.oidcConnector.client,
request,
callbackUrl,
{
expectedState: state,
expectedNonce: nonce,
...(context.oidcConnector.usePKCE
? { pkceCodeVerifier: verifier }
: {}),
},
);
+32 -28
View File
@@ -1,21 +1,10 @@
import * as oidc from 'openid-client';
import {
createCookie,
data,
type LoaderFunctionArgs,
redirect,
} from 'react-router';
import type { LoadContext } from '~/server';
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 interface OidcCookieState {
nonce: string;
state: string;
}
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
export async function loader({ request, context }: Route.LoaderArgs) {
try {
await context.sessions.auth(request);
return redirect('/');
@@ -25,25 +14,25 @@ export async function loader({
throw data('OIDC is not enabled or misconfigured', { status: 501 });
}
const cookie = createCookie('__oidc_auth_flow', {
httpOnly: true,
maxAge: 300,
secure: context.config.server.cookie_secure,
domain: context.config.server.cookie_domain,
});
const redirectUri =
context.config.oidc?.redirect_uri ?? getRedirectUri(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 url = oidc.buildAuthorizationUrl(context.oidcConnector.client, {
...(context.oidcConnector.extraParams ?? {}),
scope: context.oidcConnector.scope,
redirect_uri: redirectUri,
redirect_uri,
state,
nonce,
...(context.oidcConnector.usePKCE
? {
code_challenge_method: 'S256',
code_challenge: await oidc.calculatePKCECodeChallenge(verifier),
}
: {}),
});
return redirect(url.href, {
@@ -52,12 +41,27 @@ export async function loader({
'Set-Cookie': await cookie.serialize({
state,
nonce,
} satisfies OidcCookieState),
verifier,
redirect_uri,
}),
},
});
}
function getRedirectUri(req: Request) {
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.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) {
+30 -1
View File
@@ -1,4 +1,5 @@
import { type } from 'arktype';
import log from '~/utils/log';
import DockerIntegration from './integration/docker';
import KubernetesIntegration from './integration/kubernetes';
import ProcIntegration from './integration/proc';
@@ -14,6 +15,7 @@ export const pathSupportedKeys = [
const serverConfig = type({
host: 'string.ip = "0.0.0.0"',
port: 'number.integer = 3000',
base_url: 'string.url?',
data_path: 'string.lower = "/var/lib/headplane/"',
cookie_secret: '(32 <= string <= 32)',
@@ -25,6 +27,7 @@ const serverConfig = type({
const partialServerConfig = type({
host: 'string.ip?',
port: 'number.integer?',
base_url: 'string.url?',
data_path: 'string.lower?',
cookie_secret: '(32 <= string <= 32)?',
@@ -62,7 +65,32 @@ const oidcConfig = type({
client_id: 'string',
client_secret: 'string',
headscale_api_key: 'string',
redirect_uri: 'string.url?',
use_pkce: 'boolean = false',
redirect_uri: type('string.url')
.pipe((value, ctx) => {
log.warn(
'config',
'%s is deprecated and will be removed in 0.7.0',
ctx.propString,
);
const cleanedValue = new URL(value.trim());
if (cleanedValue.pathname.endsWith(`${__PREFIX__}/oidc/callback`)) {
cleanedValue.pathname = cleanedValue.pathname.replace(
`${__PREFIX__}/oidc/callback`,
'/',
);
log.warn(
'config',
'Please migrate to using `server.base_url` with a value of "%s"',
cleanedValue.toString(),
);
}
return cleanedValue.toString();
})
.optional(),
disable_api_key_login: 'boolean = false',
scope: 'string = "openid email profile"',
profile_picture_source: '"oidc" | "gravatar" = "oidc"',
@@ -84,6 +112,7 @@ const partialOidcConfig = type({
issuer: 'string.url?',
client_id: 'string?',
client_secret: 'string?',
use_pkce: 'boolean?',
headscale_api_key: 'string?',
redirect_uri: 'string.url?',
disable_api_key_login: 'boolean?',
+1
View File
@@ -83,6 +83,7 @@ const appLoadContext = {
integration: await loadIntegration(config.integration),
oidcConnector: config.oidc
? await createOidcConnector(
config.server.base_url,
config.oidc,
hsApi.getRuntimeClient(config.oidc.headscale_api_key),
)
+18 -2
View File
@@ -25,6 +25,7 @@ export type OidcConnector =
| {
isValid: true;
isExclusive: boolean;
usePKCE: boolean;
client: oidc.Configuration;
apiKey: string;
scope: string;
@@ -40,16 +41,23 @@ export type OidcConnector =
* Creates an OIDC connector based on the configuration and Headscale API.
* This will attempt to validate the configuration and return any errors.
*
* @param baseUrl The base URL of the Headplane server.
* @param config The OIDC configuration.
* @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,
): Promise<OidcConnector> {
// TODO: MEANINGFUL LOGS NOT JUST DEBUG SPAM LOL
//
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');
@@ -89,6 +97,7 @@ export async function createOidcConnector(
return {
isValid: true,
isExclusive: config.disable_api_key_login,
usePKCE: config.use_pkce,
client: oidcClientOrErrors,
apiKey: config.headscale_api_key,
scope: config.scope,
@@ -113,6 +122,13 @@ async function discoveryCoalesce(
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 != null) {
if (!metadata.claims_supported.includes('sub')) {
log.error('config', 'OIDC provider does not support `sub` claim');
+49
View File
@@ -0,0 +1,49 @@
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;
}
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`,
});
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;
}
return {
nonce: parsed.nonce,
state: parsed.state,
verifier: parsed.verifier,
redirect_uri: parsed.redirect_uri,
};
},
};
}
+11 -7
View File
@@ -4,6 +4,11 @@ server:
host: "0.0.0.0"
port: 3000
# The base URL for Headplane. Please keep in mind that this will be required
# for Headscale to properly function going forward AND it should not include
# the dashboard prefix (/admin) portion.
base_url: "http://localhost:3000"
# The secret used to encode and decode web sessions (must be 32 characters)
# You may also provide `cookie_secret_path` instead to read a value from disk.
# See https://headplane.net/configuration/#sensitive-values
@@ -180,16 +185,15 @@ integration:
# See https://headplane.net/configuration/#sensitive-values
# client_secret: "<your-client-secret>"
# Whether to use PKCE when authenticating users. This is recommended as it
# adds an extra layer of security to the authentication process. Enabling this
# means your OIDC provider must support PKCE and it must be enabled on the
# client.
# use_pkce: true
# If you want to disable traditional login via Headscale API keys
# disable_api_key_login: false
# Optional, but highly recommended otherwise Headplane
# will attempt to automatically guess this from the issuer
#
# This should point to your publicly accessibly URL
# for your Headplane instance with /admin/oidc/callback
# redirect_uri: "http://localhost:3000/admin/oidc/callback"
# By default profile pictures are pulled from the OIDC provider when
# we go to fetch the userinfo endpoint. Optionally, this can be set to
# "oidc" or "gravatar" as of 0.6.1.
+100 -1
View File
@@ -11,4 +11,103 @@ description: Configure Single Sign-On (SSO) authentication for Headplane.
<figcaption>SSO Configuration Page</figcaption>
</figure>
TODO
Headplane supports Single Sign-On (SSO) authentication using OpenID Connect
(OIDC). This allows users to authenticate using an external Identity Provider
(IdP) that supports OIDC, streamlining the login process and enhancing security.
This is generally the recommended authentication method when using Headplane in
production environments as it provides the deepest integration with Headscale
and allows for seamless user management.
## Configuring OIDC
To configure Single Sign-On (SSO) you'll need to first setup a client with your
Identity Provider that supports OIDC. The exact steps to do this will vary, but
generally you'll need to be able to provide the following information to
Headplane:
| Field | Description |
|---------------------------|--------------------------------------------------|
| **Client ID** | The client identifier provided by your IdP. |
| **Client Secret** | The client secret provided by your IdP. |
| **Issuer URL** | The OIDC issuer URL given by your IdP. |
::: tip
If you are using a custom prefix other than `/admin` for the Headplane web UI,
please ensure that you adjust the redirect URL accordingly when setting up
your OIDC client.
:::
Before creating the client, configure Headplane to use a redirect URL that your
IdP will accept. You'll need to set **`server.base_url`** to the public URL of your
Headplane instance in your configuration file. For example, if your Headplane
instance runs on `https://headplane.example.com/admin`, set:
```yaml
server:
base_url: "https://headplane.example.com"
```
and provide the following redirect URL to your IdP when creating the client:
```
https://headplane.example.com/admin/auth/callback
```
### Headscale API Key
Once you have created the client with your IdP, you'll need to generate an API
key for Headplane to use when communicating with Headscale. You can do this by
running `headscale apikeys create -e 1y` to create an API key that is valid for
one year (you can adjust the expiration as needed). Make sure to copy the
generated API key as you will need it for the Headplane configuration.
### Headplane Configuration
Finally, you can configure Headplane to use OIDC by adding the following fields
to your Headplane configuration file:
```yaml
oidc:
issuer: "https://your-idp.com"
client_id: "your-client-id"
client_secret: "your-client-secret"
headscale_api_key: "<generated-api-key>"
# Those options should generally be sufficient, but you can also set these:
# authorization_endpoint: ""
# token_endpoint: ""
# userinfo_endpoint: ""
# scope: "openid email profile"
# extra_params:
# foo: "bar"
# baz: "qux"
```
### PKCE
By default, Headplane does not use PKCE (Proof Key for Code Exchange) when
communicating with the Identity Provider. PKCE is generally a best practice for
OIDC and can enhance security. To enable PKCE you'll need to set `oidc.use_pkce`
to `true` in your Headplane configuration file:
```yaml
oidc:
use_pkce: true
```
You'll also need to ensure that your Identity Provider supports PKCE and is
properly configured to handle PKCE requests from Headplane.
## Troubleshooting
Some of the common issues you may encounter when configuring OIDC with Headplane
include:
- **Invalid API Key**: Ensure that the API key provided to Headplane is valid
and has not expired.
- **Missing [some]_endpoint**: If your IdP does not provide standard OIDC
endpoints, you may need to manually specify them in the Headplane configuration.
- **Missing the `sub` claim**: Ensure that your IdP is configured to include the
`sub` claim in the ID token, as this is required for Headplane to identify users.
- **Redirect URI Mismatch**: Ensure that the redirect URI configured in your IdP
and that `server.base_url` in Headplane match exactly.
- **Cookie Issues**: The OIDC authentication relies on your cookie configuration
for Headplane. If OIDC cannot complete due to a missing session or invalid
session then please check your cookie settings.