mirror of
https://github.com/tale/headplane.git
synced 2026-08-27 15:37:04 +00:00
feat: pkce
This commit is contained in:
@@ -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)).
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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?',
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user