feat: support gravatar profile pictures for oidc
This commit is contained in:
parent
9183f805a6
commit
4351e1fcb3
@ -21,6 +21,7 @@
|
|||||||
- The nix overlay build is fixed for the SSH module (via [#282](https://github.com/tale/headplane/pull/282))
|
- 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.
|
- 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)).
|
||||||
|
|
||||||
### 0.6.0 (May 25, 2025)
|
### 0.6.0 (May 25, 2025)
|
||||||
- Headplane 0.6.0 now requires **Headscale 0.26.0** or newer.
|
- Headplane 0.6.0 now requires **Headscale 0.26.0** or newer.
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
import { count } from 'drizzle-orm';
|
import { count } from 'drizzle-orm';
|
||||||
import { createCookie, type LoaderFunctionArgs, redirect } from 'react-router';
|
import { createCookie, 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 { HeadplaneConfig } from '~/server/config/schema';
|
||||||
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 { finishAuthFlow, formatError } from '~/utils/oidc';
|
import { FlowUser, finishAuthFlow, formatError } from '~/utils/oidc';
|
||||||
import { send } from '~/utils/res';
|
import { send } from '~/utils/res';
|
||||||
|
|
||||||
interface OidcFlowSession {
|
interface OidcFlowSession {
|
||||||
@ -60,7 +62,14 @@ export async function loader({
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await finishAuthFlow(context.oidc, flowOptions);
|
let user = await finishAuthFlow(context.oidc, flowOptions);
|
||||||
|
user = {
|
||||||
|
...user,
|
||||||
|
picture: setOidcPictureForSource(
|
||||||
|
user,
|
||||||
|
context.config.oidc?.profile_picture_source ?? 'oidc',
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
const [{ count: userCount }] = await context.db
|
const [{ count: userCount }] = await context.db
|
||||||
.select({ count: count() })
|
.select({ count: count() })
|
||||||
@ -96,3 +105,26 @@ export async function loader({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PictureSource = NonNullable<
|
||||||
|
HeadplaneConfig['oidc']
|
||||||
|
>['profile_picture_source'];
|
||||||
|
|
||||||
|
function setOidcPictureForSource(user: FlowUser, source: PictureSource) {
|
||||||
|
// Already set by default in the callback, so we can just return it
|
||||||
|
if (source === 'oidc') {
|
||||||
|
return user.picture;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source === 'gravatar') {
|
||||||
|
if (!user.email) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailHash = user.email.trim().toLowerCase();
|
||||||
|
const hash = createHash('sha256').update(emailHash).digest('hex');
|
||||||
|
return `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon&r=x`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|||||||
@ -63,6 +63,7 @@ const oidcConfig = type({
|
|||||||
disable_api_key_login: stringToBool,
|
disable_api_key_login: stringToBool,
|
||||||
headscale_api_key: 'string?',
|
headscale_api_key: 'string?',
|
||||||
headscale_api_key_path: 'string?',
|
headscale_api_key_path: 'string?',
|
||||||
|
profile_picture_source: '"oidc" | "gravatar" = "oidc"',
|
||||||
strict_validation: stringToBool.default(true),
|
strict_validation: stringToBool.default(true),
|
||||||
})
|
})
|
||||||
.narrow((obj: Record<string, unknown>, ctx: any) => {
|
.narrow((obj: Record<string, unknown>, ctx: any) => {
|
||||||
@ -94,6 +95,7 @@ const partialOidcConfig = type({
|
|||||||
disable_api_key_login: stringToBool.optional(),
|
disable_api_key_login: stringToBool.optional(),
|
||||||
headscale_api_key: 'string?',
|
headscale_api_key: 'string?',
|
||||||
headscale_api_key_path: 'string?',
|
headscale_api_key_path: 'string?',
|
||||||
|
profile_picture_source: '("oidc" | "gravatar")?',
|
||||||
strict_validation: stringToBool.default(true),
|
strict_validation: stringToBool.default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -69,10 +69,18 @@ interface FlowOptions {
|
|||||||
nonce?: string;
|
nonce?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FlowUser {
|
||||||
|
subject: string;
|
||||||
|
name: string;
|
||||||
|
email: string | undefined;
|
||||||
|
username: string | undefined;
|
||||||
|
picture: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export async function finishAuthFlow(
|
export async function finishAuthFlow(
|
||||||
config: Configuration,
|
config: Configuration,
|
||||||
options: FlowOptions,
|
options: FlowOptions,
|
||||||
) {
|
): Promise<FlowUser> {
|
||||||
const tokens = await client.authorizationCodeGrant(
|
const tokens = await client.authorizationCodeGrant(
|
||||||
config,
|
config,
|
||||||
new URL(options.redirect_uri),
|
new URL(options.redirect_uri),
|
||||||
|
|||||||
@ -164,3 +164,8 @@ oidc:
|
|||||||
# Stores the users and their permissions for Headplane
|
# Stores the users and their permissions for Headplane
|
||||||
# This is a path to a JSON file, default is specified below.
|
# This is a path to a JSON file, default is specified below.
|
||||||
user_storage_file: "/var/lib/headplane/users.json"
|
user_storage_file: "/var/lib/headplane/users.json"
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
# profile_picture_source: "gravatar"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user