feat: pkce

This commit is contained in:
Aarnav Tale
2025-12-04 11:34:17 -05:00
parent 3eff763436
commit 3a3e5ca65e
10 changed files with 55 additions and 24 deletions
+1
View File
@@ -11,6 +11,7 @@
- Added better testing and validation for configuration loading - 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. - 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. - 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. - 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)). - 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)). - 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
@@ -47,7 +47,7 @@ function getErrorMessage(code: string) {
case 'error_auth_failed': case 'error_auth_failed':
return ( return (
<Card.Text> <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. Headplane logs may provide more information.
</Card.Text> </Card.Text>
); );
-2
View File
@@ -1,7 +1,6 @@
import { AlertCircle } from 'lucide-react'; import { AlertCircle } from 'lucide-react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { import {
data,
Form, Form,
Link as RemixLink, Link as RemixLink,
redirect, redirect,
@@ -12,7 +11,6 @@ import Card from '~/components/Card';
import Code from '~/components/Code'; import Code from '~/components/Code';
import Input from '~/components/Input'; import Input from '~/components/Input';
import Link from '~/components/Link'; import Link from '~/components/Link';
import { OidcConnectorError } from '~/server/web/oidc-connector';
import { useLiveData } from '~/utils/live-data'; import { useLiveData } from '~/utils/live-data';
import type { Route } from './+types/page'; import type { Route } from './+types/page';
import { loginAction } from './action'; import { loginAction } from './action';
+8 -8
View File
@@ -1,18 +1,15 @@
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { count, eq } from 'drizzle-orm'; import { count, eq } from 'drizzle-orm';
import * as oidc from 'openid-client'; import * as oidc from 'openid-client';
import { data, type LoaderFunctionArgs, redirect } from 'react-router'; import { data, redirect } from 'react-router';
import { ulid } from 'ulidx'; import { ulid } from 'ulidx';
import type { LoadContext } from '~/server';
import { users } from '~/server/db/schema'; import { users } from '~/server/db/schema';
import { Roles } from '~/server/web/roles'; import { Roles } from '~/server/web/roles';
import log from '~/utils/log'; import log from '~/utils/log';
import { createOidcStateCookie } from '~/utils/oidc-state'; import { createOidcStateCookie } from '~/utils/oidc-state';
import type { Route } from './+types/oidc-callback';
export async function loader({ export async function loader({ request, context }: Route.LoaderArgs) {
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
if (!context.oidcConnector?.isValid) { if (!context.oidcConnector?.isValid) {
throw data('OIDC is not enabled or misconfigured', { status: 501 }); throw data('OIDC is not enabled or misconfigured', { status: 501 });
} }
@@ -30,8 +27,8 @@ export async function loader({
return redirect('/login?s=error_no_session'); return redirect('/login?s=error_no_session');
} }
const { state, nonce, redirect_uri } = oidcCookieState; const { state, nonce, redirect_uri, verifier } = oidcCookieState;
if (!state || !nonce || !redirect_uri) { if (!state || !nonce || !redirect_uri || !verifier) {
log.warn('auth', 'OIDC session cookie is missing required fields'); log.warn('auth', 'OIDC session cookie is missing required fields');
return redirect('/login?s=error_invalid_session'); return redirect('/login?s=error_invalid_session');
} }
@@ -47,6 +44,9 @@ export async function loader({
{ {
expectedState: state, expectedState: state,
expectedNonce: nonce, expectedNonce: nonce,
...(context.oidcConnector.usePKCE
? { pkceCodeVerifier: verifier }
: {}),
}, },
); );
+11 -6
View File
@@ -1,13 +1,10 @@
import * as oidc from 'openid-client'; import * as oidc from 'openid-client';
import { data, type LoaderFunctionArgs, redirect } from 'react-router'; import { data, redirect } from 'react-router';
import type { LoadContext } from '~/server';
import { HeadplaneConfig } from '~/server/config/config-schema'; import { HeadplaneConfig } from '~/server/config/config-schema';
import { createOidcStateCookie } from '~/utils/oidc-state'; import { createOidcStateCookie } from '~/utils/oidc-state';
import type { Route } from './+types/oidc-start';
export async function loader({ export async function loader({ request, context }: Route.LoaderArgs) {
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
try { try {
await context.sessions.auth(request); await context.sessions.auth(request);
return redirect('/'); return redirect('/');
@@ -21,6 +18,7 @@ export async function loader({
const redirect_uri = getRedirectUri(context.config, request); const redirect_uri = getRedirectUri(context.config, request);
const nonce = oidc.randomNonce(); const nonce = oidc.randomNonce();
const verifier = oidc.randomPKCECodeVerifier();
const state = oidc.randomState(); const state = oidc.randomState();
const url = oidc.buildAuthorizationUrl(context.oidcConnector.client, { const url = oidc.buildAuthorizationUrl(context.oidcConnector.client, {
@@ -29,6 +27,12 @@ export async function loader({
redirect_uri, redirect_uri,
state, state,
nonce, nonce,
...(context.oidcConnector.usePKCE
? {
code_challenge_method: 'S256',
code_challenge: await oidc.calculatePKCECodeChallenge(verifier),
}
: {}),
}); });
return redirect(url.href, { return redirect(url.href, {
@@ -37,6 +41,7 @@ export async function loader({
'Set-Cookie': await cookie.serialize({ 'Set-Cookie': await cookie.serialize({
state, state,
nonce, nonce,
verifier,
redirect_uri, redirect_uri,
}), }),
}, },
+2
View File
@@ -65,6 +65,7 @@ const oidcConfig = type({
client_id: 'string', client_id: 'string',
client_secret: 'string', client_secret: 'string',
headscale_api_key: 'string', headscale_api_key: 'string',
use_pkce: 'boolean = false',
redirect_uri: type('string.url') redirect_uri: type('string.url')
.pipe((value, ctx) => { .pipe((value, ctx) => {
log.warn( log.warn(
@@ -111,6 +112,7 @@ const partialOidcConfig = type({
issuer: 'string.url?', issuer: 'string.url?',
client_id: 'string?', client_id: 'string?',
client_secret: 'string?', client_secret: 'string?',
use_pkce: 'boolean?',
headscale_api_key: 'string?', headscale_api_key: 'string?',
redirect_uri: 'string.url?', redirect_uri: 'string.url?',
disable_api_key_login: 'boolean?', disable_api_key_login: 'boolean?',
+9
View File
@@ -25,6 +25,7 @@ export type OidcConnector =
| { | {
isValid: true; isValid: true;
isExclusive: boolean; isExclusive: boolean;
usePKCE: boolean;
client: oidc.Configuration; client: oidc.Configuration;
apiKey: string; apiKey: string;
scope: string; scope: string;
@@ -96,6 +97,7 @@ export async function createOidcConnector(
return { return {
isValid: true, isValid: true,
isExclusive: config.disable_api_key_login, isExclusive: config.disable_api_key_login,
usePKCE: config.use_pkce,
client: oidcClientOrErrors, client: oidcClientOrErrors,
apiKey: config.headscale_api_key, apiKey: config.headscale_api_key,
scope: config.scope, scope: config.scope,
@@ -120,6 +122,13 @@ async function discoveryCoalesce(
config.client_id, config.client_id,
); );
metadata = client.serverMetadata(); 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 != null) {
if (!metadata.claims_supported.includes('sub')) { if (!metadata.claims_supported.includes('sub')) {
log.error('config', 'OIDC provider does not support `sub` claim'); log.error('config', 'OIDC provider does not support `sub` claim');
+3
View File
@@ -4,6 +4,7 @@ import type { HeadplaneConfig } from '~/server/config/config-schema';
export interface OidcStateCookie { export interface OidcStateCookie {
nonce: string; nonce: string;
state: string; state: string;
verifier: string;
redirect_uri: string; redirect_uri: string;
} }
@@ -31,6 +32,7 @@ export function createOidcStateCookie(config: HeadplaneConfig) {
typeof parsed !== 'object' || typeof parsed !== 'object' ||
typeof parsed.nonce !== 'string' || typeof parsed.nonce !== 'string' ||
typeof parsed.state !== 'string' || typeof parsed.state !== 'string' ||
typeof parsed.verifier !== 'string' ||
typeof parsed.redirect_uri !== 'string' typeof parsed.redirect_uri !== 'string'
) { ) {
return null; return null;
@@ -39,6 +41,7 @@ export function createOidcStateCookie(config: HeadplaneConfig) {
return { return {
nonce: parsed.nonce, nonce: parsed.nonce,
state: parsed.state, state: parsed.state,
verifier: parsed.verifier,
redirect_uri: parsed.redirect_uri, redirect_uri: parsed.redirect_uri,
}; };
}, },
+6 -7
View File
@@ -185,16 +185,15 @@ integration:
# See https://headplane.net/configuration/#sensitive-values # See https://headplane.net/configuration/#sensitive-values
# client_secret: "<your-client-secret>" # 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 # If you want to disable traditional login via Headscale API keys
# disable_api_key_login: false # 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 # By default profile pictures are pulled from the OIDC provider when
# we go to fetch the userinfo endpoint. Optionally, this can be set to # we go to fetch the userinfo endpoint. Optionally, this can be set to
# "oidc" or "gravatar" as of 0.6.1. # "oidc" or "gravatar" as of 0.6.1.
+14
View File
@@ -82,6 +82,20 @@ oidc:
# baz: "qux" # 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 ## Troubleshooting
Some of the common issues you may encounter when configuring OIDC with Headplane Some of the common issues you may encounter when configuring OIDC with Headplane
include: include: