mirror of
https://github.com/tale/headplane.git
synced 2026-10-26 06:30:12 +00:00
fix(oidc): sync profile picture with the provider on every sign-in
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) }
|
||||
: {}),
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user