fix(oidc): sync profile picture with the provider on every sign-in

This commit is contained in:
Aarnav Tale
2026-10-17 14:30:05 -07:00
parent bc935e7c66
commit 078a18d0cb
5 changed files with 51 additions and 2 deletions
+1
View File
@@ -25,6 +25,7 @@
## Fixes
- Profile pictures from OIDC now correctly update on each sign-in. If the provider stops sending pictures, Headplane removes the picture instead of leaving a potentially broken image behind.
- A machine's page now requires permission to view machines, like the machines list. Previously anyone signed in could open it by its address.
- Errors on the machine page and in Settings no longer replace the whole app with an error message. The navigation bar and the Settings sidebar stay so you can move on.
- The agent page loads while Headscale is unreachable, showing the agent's sync status without the machine details.
+5 -1
View File
@@ -707,8 +707,12 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
!claims.name && !claims.email && !claims.picture && !!resolveSubject(claims);
const needsSubjectEnrichment = !resolveSubject(claims);
const needsRoleEnrichment = !!config.roleClaim && claims[config.roleClaim] === undefined;
const needsPictureEnrichment = !claims.picture && config.profilePictureSource !== "gravatar";
if (
(!needsEnrichment && !needsSubjectEnrichment && !needsRoleEnrichment) ||
(!needsEnrichment &&
!needsSubjectEnrichment &&
!needsRoleEnrichment &&
!needsPictureEnrichment) ||
!ep.userinfoEndpoint
) {
return claims;
+1 -1
View File
@@ -609,7 +609,7 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
.set({
name: profile?.name,
email: profile?.email,
picture: profile?.picture,
picture: profile?.picture ?? null,
...(syncedRole && existing.role !== "owner"
? { role: syncedRole, caps: capsForRole(syncedRole) }
: {}),
+9
View File
@@ -66,6 +66,15 @@ describe("findOrCreateUser", () => {
expect(user?.email).toBe("new@test.com");
});
test("clears picture when the provider stops sending one", async () => {
await auth.findOrCreateUser("sub-1", { name: "Alice", picture: "https://old.test/a.png" });
await auth.findOrCreateUser("sub-1", { name: "Alice" });
const users = await auth.listUsers();
const user = users.find((u) => u.sub === "sub-1");
expect(user?.picture).toBeNull();
});
test("syncs an existing non-owner role when explicitly provided", async () => {
await auth.findOrCreateUser("sub-owner");
await auth.findOrCreateUser("sub-1", { name: "User" });
+35
View File
@@ -1084,6 +1084,41 @@ describe("userinfo enrichment", () => {
expect(userinfoCalledCount).toBe(0);
});
test("fetches picture from userinfo when id token has profile claims without one", async () => {
const svc = createOidcService(testConfig({ usePkce: false }));
const flowResult = await svc.startFlow();
if (!flowResult.ok) {
throw new Error("startFlow failed");
}
const { flowState } = flowResult.value;
const idToken = await signIdToken(
{ sub: "user-123", name: "From Token", email: "token@example.com" },
flowState.nonce,
);
tokenHandler = async (_req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ access_token: "at", id_token: idToken, token_type: "Bearer" }));
};
userinfoHandler = (_req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ name: "Should Not Use", picture: "https://example.com/pic.jpg" }));
};
const params = new URLSearchParams({ code: "c", state: flowState.state });
const result = await svc.handleCallback(params, flowState);
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.value.name).toBe("From Token");
expect(result.value.picture).toBe("https://example.com/pic.jpg");
});
test("userinfo failure does not block login", async () => {
const svc = createOidcService(testConfig({ usePkce: false }));
const flowResult = await svc.startFlow();