diff --git a/CHANGELOG.md b/CHANGELOG.md index 124fb56..92ada15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/app/routes/auth/oidc-callback.ts b/app/routes/auth/oidc-callback.ts index eec883b..c522ef9 100644 --- a/app/routes/auth/oidc-callback.ts +++ b/app/routes/auth/oidc-callback.ts @@ -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; +} diff --git a/app/server/config/schema.ts b/app/server/config/schema.ts index c1a9be6..691ba69 100644 --- a/app/server/config/schema.ts +++ b/app/server/config/schema.ts @@ -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, 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), }); diff --git a/app/utils/oidc.ts b/app/utils/oidc.ts index 788029c..ed31162 100644 --- a/app/utils/oidc.ts +++ b/app/utils/oidc.ts @@ -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 { const tokens = await client.authorizationCodeGrant( config, new URL(options.redirect_uri), diff --git a/config.example.yaml b/config.example.yaml index 0c827bb..7664854 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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"