mirror of
https://github.com/tale/headplane.git
synced 2026-08-21 02:06:37 +00:00
fix: correctly passthrough and handle a new OIDC redirect_uri system
This commit is contained in:
@@ -10,6 +10,7 @@
|
|||||||
- Secret path loading has been reworked from the ground up to be more reliable (closes [#334](https://github.com/tale/headplane/issues/334)).
|
- 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
|
- 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.
|
||||||
- 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,18 +1,13 @@
|
|||||||
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 {
|
import { data, type LoaderFunctionArgs, redirect } from 'react-router';
|
||||||
createCookie,
|
|
||||||
data,
|
|
||||||
type LoaderFunctionArgs,
|
|
||||||
redirect,
|
|
||||||
} from 'react-router';
|
|
||||||
import { ulid } from 'ulidx';
|
import { ulid } from 'ulidx';
|
||||||
import type { LoadContext } from '~/server';
|
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 type { OidcCookieState } from './oidc-start';
|
import { createOidcStateCookie } from '~/utils/oidc-state';
|
||||||
|
|
||||||
export async function loader({
|
export async function loader({
|
||||||
request,
|
request,
|
||||||
@@ -27,32 +22,28 @@ export async function loader({
|
|||||||
return redirect('/login?s=error_no_query');
|
return redirect('/login?s=error_no_query');
|
||||||
}
|
}
|
||||||
|
|
||||||
const cookie = createCookie('__oidc_auth_flow', {
|
const cookie = createOidcStateCookie(context.config);
|
||||||
httpOnly: true,
|
const oidcCookieState = await cookie.parse(request.headers.get('Cookie'));
|
||||||
maxAge: 300,
|
|
||||||
secure: context.config.server.cookie_secure,
|
|
||||||
domain: context.config.server.cookie_domain,
|
|
||||||
});
|
|
||||||
|
|
||||||
const oidcCookieState: OidcCookieState | null = await cookie.parse(
|
if (oidcCookieState == null) {
|
||||||
request.headers.get('Cookie'),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (oidcCookieState == null || typeof oidcCookieState !== 'object') {
|
|
||||||
log.warn('auth', 'Called OIDC callback without session cookie');
|
log.warn('auth', 'Called OIDC callback without session cookie');
|
||||||
return redirect('/login?s=error_no_session');
|
return redirect('/login?s=error_no_session');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { state, nonce } = oidcCookieState;
|
const { state, nonce, redirect_uri } = oidcCookieState;
|
||||||
if (!state || !nonce) {
|
if (!state || !nonce || !redirect_uri) {
|
||||||
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');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const callbackUrl = new URL(redirect_uri);
|
||||||
|
const currentUrl = new URL(request.url);
|
||||||
|
callbackUrl.search = currentUrl.search;
|
||||||
|
|
||||||
const tokens = await oidc.authorizationCodeGrant(
|
const tokens = await oidc.authorizationCodeGrant(
|
||||||
context.oidcConnector.client,
|
context.oidcConnector.client,
|
||||||
request,
|
callbackUrl,
|
||||||
{
|
{
|
||||||
expectedState: state,
|
expectedState: state,
|
||||||
expectedNonce: nonce,
|
expectedNonce: nonce,
|
||||||
|
|||||||
@@ -1,16 +1,8 @@
|
|||||||
import * as oidc from 'openid-client';
|
import * as oidc from 'openid-client';
|
||||||
import {
|
import { data, type LoaderFunctionArgs, redirect } from 'react-router';
|
||||||
createCookie,
|
|
||||||
data,
|
|
||||||
type LoaderFunctionArgs,
|
|
||||||
redirect,
|
|
||||||
} from 'react-router';
|
|
||||||
import type { LoadContext } from '~/server';
|
import type { LoadContext } from '~/server';
|
||||||
|
import { HeadplaneConfig } from '~/server/config/config-schema';
|
||||||
export interface OidcCookieState {
|
import { createOidcStateCookie } from '~/utils/oidc-state';
|
||||||
nonce: string;
|
|
||||||
state: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loader({
|
export async function loader({
|
||||||
request,
|
request,
|
||||||
@@ -25,15 +17,8 @@ export async function loader({
|
|||||||
throw data('OIDC is not enabled or misconfigured', { status: 501 });
|
throw data('OIDC is not enabled or misconfigured', { status: 501 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const cookie = createCookie('__oidc_auth_flow', {
|
const cookie = createOidcStateCookie(context.config);
|
||||||
httpOnly: true,
|
const redirect_uri = getRedirectUri(context.config, request);
|
||||||
maxAge: 300,
|
|
||||||
secure: context.config.server.cookie_secure,
|
|
||||||
domain: context.config.server.cookie_domain,
|
|
||||||
});
|
|
||||||
|
|
||||||
const redirectUri =
|
|
||||||
context.config.oidc?.redirect_uri ?? getRedirectUri(request);
|
|
||||||
|
|
||||||
const nonce = oidc.randomNonce();
|
const nonce = oidc.randomNonce();
|
||||||
const state = oidc.randomState();
|
const state = oidc.randomState();
|
||||||
@@ -41,7 +26,7 @@ export async function loader({
|
|||||||
const url = oidc.buildAuthorizationUrl(context.oidcConnector.client, {
|
const url = oidc.buildAuthorizationUrl(context.oidcConnector.client, {
|
||||||
...(context.oidcConnector.extraParams ?? {}),
|
...(context.oidcConnector.extraParams ?? {}),
|
||||||
scope: context.oidcConnector.scope,
|
scope: context.oidcConnector.scope,
|
||||||
redirect_uri: redirectUri,
|
redirect_uri,
|
||||||
state,
|
state,
|
||||||
nonce,
|
nonce,
|
||||||
});
|
});
|
||||||
@@ -52,12 +37,26 @@ export async function loader({
|
|||||||
'Set-Cookie': await cookie.serialize({
|
'Set-Cookie': await cookie.serialize({
|
||||||
state,
|
state,
|
||||||
nonce,
|
nonce,
|
||||||
} satisfies OidcCookieState),
|
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);
|
const url = new URL(`${__PREFIX__}/oidc/callback`, req.url);
|
||||||
let host = req.headers.get('Host');
|
let host = req.headers.get('Host');
|
||||||
if (!host) {
|
if (!host) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { type } from 'arktype';
|
import { type } from 'arktype';
|
||||||
|
import log from '~/utils/log';
|
||||||
import DockerIntegration from './integration/docker';
|
import DockerIntegration from './integration/docker';
|
||||||
import KubernetesIntegration from './integration/kubernetes';
|
import KubernetesIntegration from './integration/kubernetes';
|
||||||
import ProcIntegration from './integration/proc';
|
import ProcIntegration from './integration/proc';
|
||||||
@@ -14,6 +15,7 @@ export const pathSupportedKeys = [
|
|||||||
const serverConfig = type({
|
const serverConfig = type({
|
||||||
host: 'string.ip = "0.0.0.0"',
|
host: 'string.ip = "0.0.0.0"',
|
||||||
port: 'number.integer = 3000',
|
port: 'number.integer = 3000',
|
||||||
|
base_url: 'string.url?',
|
||||||
data_path: 'string.lower = "/var/lib/headplane/"',
|
data_path: 'string.lower = "/var/lib/headplane/"',
|
||||||
|
|
||||||
cookie_secret: '(32 <= string <= 32)',
|
cookie_secret: '(32 <= string <= 32)',
|
||||||
@@ -25,6 +27,7 @@ const serverConfig = type({
|
|||||||
const partialServerConfig = type({
|
const partialServerConfig = type({
|
||||||
host: 'string.ip?',
|
host: 'string.ip?',
|
||||||
port: 'number.integer?',
|
port: 'number.integer?',
|
||||||
|
base_url: 'string.url?',
|
||||||
data_path: 'string.lower?',
|
data_path: 'string.lower?',
|
||||||
|
|
||||||
cookie_secret: '(32 <= string <= 32)?',
|
cookie_secret: '(32 <= string <= 32)?',
|
||||||
@@ -62,7 +65,31 @@ const oidcConfig = type({
|
|||||||
client_id: 'string',
|
client_id: 'string',
|
||||||
client_secret: 'string',
|
client_secret: 'string',
|
||||||
headscale_api_key: 'string',
|
headscale_api_key: 'string',
|
||||||
redirect_uri: 'string.url?',
|
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',
|
disable_api_key_login: 'boolean = false',
|
||||||
scope: 'string = "openid email profile"',
|
scope: 'string = "openid email profile"',
|
||||||
profile_picture_source: '"oidc" | "gravatar" = "oidc"',
|
profile_picture_source: '"oidc" | "gravatar" = "oidc"',
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ const appLoadContext = {
|
|||||||
integration: await loadIntegration(config.integration),
|
integration: await loadIntegration(config.integration),
|
||||||
oidcConnector: config.oidc
|
oidcConnector: config.oidc
|
||||||
? await createOidcConnector(
|
? await createOidcConnector(
|
||||||
|
config.server.base_url,
|
||||||
config.oidc,
|
config.oidc,
|
||||||
hsApi.getRuntimeClient(config.oidc.headscale_api_key),
|
hsApi.getRuntimeClient(config.oidc.headscale_api_key),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -40,16 +40,23 @@ export type OidcConnector =
|
|||||||
* Creates an OIDC connector based on the configuration and Headscale API.
|
* Creates an OIDC connector based on the configuration and Headscale API.
|
||||||
* This will attempt to validate the configuration and return any errors.
|
* 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 config The OIDC configuration.
|
||||||
* @param client The Headscale runtime API client.
|
* @param client The Headscale runtime API client.
|
||||||
* @returns An OIDC connector with validation status.
|
* @returns An OIDC connector with validation status.
|
||||||
*/
|
*/
|
||||||
export async function createOidcConnector(
|
export async function createOidcConnector(
|
||||||
|
baseUrl: string | undefined,
|
||||||
config: OidcConfig,
|
config: OidcConfig,
|
||||||
client: RuntimeApiClient,
|
client: RuntimeApiClient,
|
||||||
): Promise<OidcConnector> {
|
): 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[] = [];
|
const errors: OidcConnectorError[] = [];
|
||||||
if (!config.headscale_api_key) {
|
if (!config.headscale_api_key) {
|
||||||
errors.push('INVALID_API_KEY');
|
errors.push('INVALID_API_KEY');
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { createCookie } from 'react-router';
|
||||||
|
import type { HeadplaneConfig } from '~/server/config/config-schema';
|
||||||
|
|
||||||
|
export interface OidcStateCookie {
|
||||||
|
nonce: string;
|
||||||
|
state: 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.redirect_uri !== 'string'
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nonce: parsed.nonce,
|
||||||
|
state: parsed.state,
|
||||||
|
redirect_uri: parsed.redirect_uri,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,6 +4,11 @@ server:
|
|||||||
host: "0.0.0.0"
|
host: "0.0.0.0"
|
||||||
port: 3000
|
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)
|
# 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.
|
# You may also provide `cookie_secret_path` instead to read a value from disk.
|
||||||
# See https://headplane.net/configuration/#sensitive-values
|
# See https://headplane.net/configuration/#sensitive-values
|
||||||
|
|||||||
Reference in New Issue
Block a user