feat: support gravatar profile pictures for oidc

This commit is contained in:
Aarnav Tale
2025-08-21 12:16:11 -04:00
parent 9183f805a6
commit 4351e1fcb3
5 changed files with 51 additions and 3 deletions
+1
View File
@@ -21,6 +21,7 @@
- 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.
- 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)
- Headplane 0.6.0 now requires **Headscale 0.26.0** or newer.
+34 -2
View File
@@ -1,10 +1,12 @@
import { createHash } from 'node:crypto';
import { count } from 'drizzle-orm';
import { createCookie, type LoaderFunctionArgs, redirect } from 'react-router';
import { ulid } from 'ulidx';
import type { LoadContext } from '~/server';
import { HeadplaneConfig } from '~/server/config/schema';
import { users } from '~/server/db/schema';
import { Roles } from '~/server/web/roles';
import { finishAuthFlow, formatError } from '~/utils/oidc';
import { FlowUser, finishAuthFlow, formatError } from '~/utils/oidc';
import { send } from '~/utils/res';
interface OidcFlowSession {
@@ -60,7 +62,14 @@ export async function loader({
};
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
.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;
}
+2
View File
@@ -63,6 +63,7 @@ const oidcConfig = type({
disable_api_key_login: stringToBool,
headscale_api_key: 'string?',
headscale_api_key_path: 'string?',
profile_picture_source: '"oidc" | "gravatar" = "oidc"',
strict_validation: stringToBool.default(true),
})
.narrow((obj: Record<string, unknown>, ctx: any) => {
@@ -94,6 +95,7 @@ const partialOidcConfig = type({
disable_api_key_login: stringToBool.optional(),
headscale_api_key: 'string?',
headscale_api_key_path: 'string?',
profile_picture_source: '("oidc" | "gravatar")?',
strict_validation: stringToBool.default(true),
});
+9 -1
View File
@@ -69,10 +69,18 @@ interface FlowOptions {
nonce?: string;
}
export interface FlowUser {
subject: string;
name: string;
email: string | undefined;
username: string | undefined;
picture: string | undefined;
}
export async function finishAuthFlow(
config: Configuration,
options: FlowOptions,
) {
): Promise<FlowUser> {
const tokens = await client.authorizationCodeGrant(
config,
new URL(options.redirect_uri),
+5
View File
@@ -164,3 +164,8 @@ oidc:
# Stores the users and their permissions for Headplane
# This is a path to a JSON file, default is specified below.
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"