feat: add signed SPA account-linking helper

This commit is contained in:
donghyeon-ka
2026-07-25 16:38:01 +09:00
parent 32450c35ab
commit eec9feae5c
3 changed files with 81 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
function base64Url(bytes) {
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary)
.replaceAll("+", "-")
.replaceAll("/", "_")
.replaceAll("=", "");
}
export async function createAccountLinkUrl({
keycloakBaseUrl,
realm,
provider,
clientId,
redirectUri,
sessionState,
issuedFor,
nonce = crypto.randomUUID(),
cryptoApi = crypto,
}) {
const material = `${nonce}${sessionState}${issuedFor}${provider}`;
const digest = await cryptoApi.subtle.digest(
"SHA-256",
new TextEncoder().encode(material),
);
const url = new URL(
`${keycloakBaseUrl}/realms/${realm}/broker/${provider}/link`,
);
url.search = new URLSearchParams({
nonce,
hash: base64Url(new Uint8Array(digest)),
client_id: clientId,
redirect_uri: redirectUri,
});
return url;
}
+25
View File
@@ -0,0 +1,25 @@
import assert from "node:assert/strict";
import test from "node:test";
const { createAccountLinkUrl } = await import("../src/account-linking.js");
test("creates a signed client-initiated account-link URL", async () => {
const url = await createAccountLinkUrl({
keycloakBaseUrl: "https://auth.example.test",
realm: "keycloak-patterns",
provider: "google",
clientId: "spa-public",
redirectUri: "https://app.example.test/settings/identity",
sessionState: "session-state",
issuedFor: "spa-public",
nonce: "fixed-nonce",
});
assert.equal(
url.pathname,
"/realms/keycloak-patterns/broker/google/link",
);
assert.equal(url.searchParams.get("client_id"), "spa-public");
assert.equal(url.searchParams.get("nonce"), "fixed-nonce");
assert.match(url.searchParams.get("hash"), /^[A-Za-z0-9_-]{43}$/u);
});