feat(28-01): create Logto Management API client service with M2M auth
Implements LogtoManagementClient with token caching, password verification, password update, email update, user deletion, and has-password check. All methods proxy to Logto Management API via M2M credentials. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
204
src/server/services/logto.service.ts
Normal file
204
src/server/services/logto.service.ts
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
// ── Logto Management API Client ─────────────────────────────────────
|
||||||
|
// Machine-to-Machine (M2M) client for proxying account management
|
||||||
|
// operations through GearBox's backend, per D-04.
|
||||||
|
// Users never interact with Logto directly.
|
||||||
|
|
||||||
|
interface LogtoManagementConfig {
|
||||||
|
issuer: string;
|
||||||
|
m2mAppId: string;
|
||||||
|
m2mAppSecret: string;
|
||||||
|
apiResource: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LogtoUser {
|
||||||
|
id: string;
|
||||||
|
primaryEmail: string | null;
|
||||||
|
name: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LogtoManagementClient {
|
||||||
|
private config: LogtoManagementConfig | null = null;
|
||||||
|
private accessToken: string | null = null;
|
||||||
|
private tokenExpiry = 0;
|
||||||
|
|
||||||
|
constructor(config?: LogtoManagementConfig) {
|
||||||
|
if (config) {
|
||||||
|
this.config = config;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const issuer = process.env.OIDC_ISSUER;
|
||||||
|
const m2mAppId = process.env.LOGTO_M2M_APP_ID;
|
||||||
|
const m2mAppSecret = process.env.LOGTO_M2M_APP_SECRET;
|
||||||
|
const apiResource =
|
||||||
|
process.env.LOGTO_API_RESOURCE ?? "https://default.logto.app/api";
|
||||||
|
|
||||||
|
if (issuer && m2mAppId && m2mAppSecret) {
|
||||||
|
this.config = { issuer, m2mAppId, m2mAppSecret, apiResource };
|
||||||
|
}
|
||||||
|
// config stays null if env vars missing — methods will throw
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureConfigured(): LogtoManagementConfig {
|
||||||
|
if (!this.config) {
|
||||||
|
throw new Error(
|
||||||
|
"Logto M2M not configured. Set LOGTO_M2M_APP_ID and LOGTO_M2M_APP_SECRET env vars.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the Management API base URL from the OIDC issuer.
|
||||||
|
* Strip /oidc suffix if present, to get the Logto base URL.
|
||||||
|
*/
|
||||||
|
private getBaseUrl(): string {
|
||||||
|
const config = this.ensureConfigured();
|
||||||
|
const issuer = config.issuer.replace(/\/oidc\/?$/, "");
|
||||||
|
return issuer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a cached M2M access token, refreshing if expired.
|
||||||
|
* Per T-28-01: never log the token or secret.
|
||||||
|
* Per T-28-02: cache with TTL (expiry minus 60s buffer).
|
||||||
|
*/
|
||||||
|
async getAccessToken(): Promise<string> {
|
||||||
|
const config = this.ensureConfigured();
|
||||||
|
|
||||||
|
// Return cached token if still valid (with 60s buffer)
|
||||||
|
if (this.accessToken && Date.now() < this.tokenExpiry - 60_000) {
|
||||||
|
return this.accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenUrl = `${this.getBaseUrl()}/oidc/token`;
|
||||||
|
const credentials = Buffer.from(
|
||||||
|
`${config.m2mAppId}:${config.m2mAppSecret}`,
|
||||||
|
).toString("base64");
|
||||||
|
|
||||||
|
const res = await fetch(tokenUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
Authorization: `Basic ${credentials}`,
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: "client_credentials",
|
||||||
|
resource: config.apiResource,
|
||||||
|
scope: "all",
|
||||||
|
}).toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Logto M2M token request failed: HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
access_token: string;
|
||||||
|
expires_in: number;
|
||||||
|
};
|
||||||
|
this.accessToken = data.access_token;
|
||||||
|
this.tokenExpiry = Date.now() + data.expires_in * 1000;
|
||||||
|
|
||||||
|
return this.accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async apiRequest(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
): Promise<Response> {
|
||||||
|
const token = await this.getAccessToken();
|
||||||
|
const url = `${this.getBaseUrl()}${path}`;
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
};
|
||||||
|
if (body !== undefined) {
|
||||||
|
headers["Content-Type"] = "application/json";
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(url, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUser(logtoSub: string): Promise<LogtoUser> {
|
||||||
|
const res = await this.apiRequest("GET", `/api/users/${logtoSub}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to get Logto user: HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<LogtoUser>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a user's current password.
|
||||||
|
* Returns true if password is correct, false otherwise.
|
||||||
|
*/
|
||||||
|
async verifyPassword(logtoSub: string, password: string): Promise<boolean> {
|
||||||
|
const res = await this.apiRequest(
|
||||||
|
"POST",
|
||||||
|
`/api/users/${logtoSub}/password/verify`,
|
||||||
|
{ password },
|
||||||
|
);
|
||||||
|
if (res.status === 204 || res.status === 200) return true;
|
||||||
|
if (res.status === 422 || res.status === 400) return false;
|
||||||
|
throw new Error(`Logto verifyPassword unexpected: HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a new password for the user.
|
||||||
|
*/
|
||||||
|
async updatePassword(logtoSub: string, password: string): Promise<void> {
|
||||||
|
const res = await this.apiRequest(
|
||||||
|
"PATCH",
|
||||||
|
`/api/users/${logtoSub}/password`,
|
||||||
|
{ password },
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to update password: HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user has a password set (social-only accounts may not).
|
||||||
|
*/
|
||||||
|
async hasPassword(logtoSub: string): Promise<boolean> {
|
||||||
|
const res = await this.apiRequest(
|
||||||
|
"GET",
|
||||||
|
`/api/users/${logtoSub}/has-password`,
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to check password status: HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as { hasPassword: boolean };
|
||||||
|
return data.hasPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the user's primary email on Logto.
|
||||||
|
*/
|
||||||
|
async updateEmail(logtoSub: string, email: string): Promise<void> {
|
||||||
|
const res = await this.apiRequest("PATCH", `/api/users/${logtoSub}`, {
|
||||||
|
primaryEmail: email,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to update email: HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the user from Logto entirely.
|
||||||
|
*/
|
||||||
|
async deleteUser(logtoSub: string): Promise<void> {
|
||||||
|
const res = await this.apiRequest("DELETE", `/api/users/${logtoSub}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to delete Logto user: HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const logtoClient = new LogtoManagementClient();
|
||||||
266
tests/services/logto.service.test.ts
Normal file
266
tests/services/logto.service.test.ts
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
||||||
|
import { LogtoManagementClient } from "../../src/server/services/logto.service";
|
||||||
|
|
||||||
|
// Mock fetch at module level
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
describe("LogtoManagementClient", () => {
|
||||||
|
let client: LogtoManagementClient;
|
||||||
|
let fetchMock: ReturnType<typeof mock>;
|
||||||
|
let fetchCalls: Array<{ url: string; options: RequestInit }>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchCalls = [];
|
||||||
|
fetchMock = mock((url: string, options: RequestInit) => {
|
||||||
|
fetchCalls.push({ url: url.toString(), options });
|
||||||
|
|
||||||
|
// Default: token endpoint
|
||||||
|
if (url.includes("/oidc/token")) {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
access_token: "test-token-123",
|
||||||
|
expires_in: 3600,
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET user
|
||||||
|
if (url.includes("/api/users/") && options.method === "GET" && !url.includes("has-password")) {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
id: "logto-sub-123",
|
||||||
|
primaryEmail: "user@example.com",
|
||||||
|
name: "Test User",
|
||||||
|
avatar: null,
|
||||||
|
createdAt: 1700000000000,
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// has-password
|
||||||
|
if (url.includes("has-password")) {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(JSON.stringify({ hasPassword: true }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// verify password
|
||||||
|
if (url.includes("password/verify")) {
|
||||||
|
return Promise.resolve(new Response(null, { status: 204 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH password
|
||||||
|
if (url.includes("/password") && options.method === "PATCH") {
|
||||||
|
return Promise.resolve(new Response(null, { status: 200 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH user (email update)
|
||||||
|
if (options.method === "PATCH" && !url.includes("/password")) {
|
||||||
|
return Promise.resolve(new Response(null, { status: 200 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE user
|
||||||
|
if (options.method === "DELETE") {
|
||||||
|
return Promise.resolve(new Response(null, { status: 204 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(new Response(null, { status: 404 }));
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
globalThis.fetch = fetchMock as any;
|
||||||
|
|
||||||
|
client = new LogtoManagementClient({
|
||||||
|
issuer: "https://logto.example.com/oidc",
|
||||||
|
m2mAppId: "test-app-id",
|
||||||
|
m2mAppSecret: "test-app-secret",
|
||||||
|
apiResource: "https://default.logto.app/api",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getAccessToken fetches token via client_credentials grant", async () => {
|
||||||
|
const token = await client.getAccessToken();
|
||||||
|
expect(token).toBe("test-token-123");
|
||||||
|
|
||||||
|
const tokenCall = fetchCalls.find((c) => c.url.includes("/oidc/token"));
|
||||||
|
expect(tokenCall).toBeDefined();
|
||||||
|
expect(tokenCall!.options.method).toBe("POST");
|
||||||
|
expect(tokenCall!.options.headers).toHaveProperty(
|
||||||
|
"Content-Type",
|
||||||
|
"application/x-www-form-urlencoded",
|
||||||
|
);
|
||||||
|
// Check Authorization header uses Basic auth
|
||||||
|
const authHeader = (tokenCall!.options.headers as Record<string, string>)
|
||||||
|
.Authorization;
|
||||||
|
expect(authHeader).toStartWith("Basic ");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getAccessToken returns cached token on second call", async () => {
|
||||||
|
await client.getAccessToken();
|
||||||
|
await client.getAccessToken();
|
||||||
|
|
||||||
|
// Only one call to token endpoint — second call uses cache
|
||||||
|
const tokenCalls = fetchCalls.filter((c) =>
|
||||||
|
c.url.includes("/oidc/token"),
|
||||||
|
);
|
||||||
|
expect(tokenCalls.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getAccessToken refreshes token when expired", async () => {
|
||||||
|
// Get initial token
|
||||||
|
await client.getAccessToken();
|
||||||
|
|
||||||
|
// Simulate expiry by manipulating internal state
|
||||||
|
// Access private field via any cast
|
||||||
|
(client as any).tokenExpiry = Date.now() - 1000;
|
||||||
|
|
||||||
|
await client.getAccessToken();
|
||||||
|
|
||||||
|
const tokenCalls = fetchCalls.filter((c) =>
|
||||||
|
c.url.includes("/oidc/token"),
|
||||||
|
);
|
||||||
|
expect(tokenCalls.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("verifyPassword calls correct endpoint and returns true on 204", async () => {
|
||||||
|
const result = await client.verifyPassword("sub-123", "my-password");
|
||||||
|
expect(result).toBe(true);
|
||||||
|
|
||||||
|
const verifyCall = fetchCalls.find((c) =>
|
||||||
|
c.url.includes("/api/users/sub-123/password/verify"),
|
||||||
|
);
|
||||||
|
expect(verifyCall).toBeDefined();
|
||||||
|
expect(verifyCall!.options.method).toBe("POST");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("verifyPassword returns false on 422", async () => {
|
||||||
|
// Override fetch for this specific test
|
||||||
|
globalThis.fetch = mock((url: string, options: RequestInit) => {
|
||||||
|
if (url.includes("/oidc/token")) {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ access_token: "token", expires_in: 3600 }),
|
||||||
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (url.includes("password/verify")) {
|
||||||
|
return Promise.resolve(new Response(null, { status: 422 }));
|
||||||
|
}
|
||||||
|
return Promise.resolve(new Response(null, { status: 404 }));
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
// Need fresh client to reset cached token
|
||||||
|
const freshClient = new LogtoManagementClient({
|
||||||
|
issuer: "https://logto.example.com/oidc",
|
||||||
|
m2mAppId: "test-app-id",
|
||||||
|
m2mAppSecret: "test-app-secret",
|
||||||
|
apiResource: "https://default.logto.app/api",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await freshClient.verifyPassword("sub-123", "wrong-pass");
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updatePassword calls PATCH on password endpoint", async () => {
|
||||||
|
await client.updatePassword("sub-123", "new-secure-password");
|
||||||
|
|
||||||
|
const patchCall = fetchCalls.find(
|
||||||
|
(c) =>
|
||||||
|
c.url.includes("/api/users/sub-123/password") &&
|
||||||
|
c.options.method === "PATCH" &&
|
||||||
|
!c.url.includes("verify"),
|
||||||
|
);
|
||||||
|
expect(patchCall).toBeDefined();
|
||||||
|
const body = JSON.parse(patchCall!.options.body as string);
|
||||||
|
expect(body.password).toBe("new-secure-password");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hasPassword calls GET has-password endpoint and returns boolean", async () => {
|
||||||
|
const result = await client.hasPassword("sub-123");
|
||||||
|
expect(result).toBe(true);
|
||||||
|
|
||||||
|
const hasPassCall = fetchCalls.find((c) =>
|
||||||
|
c.url.includes("/api/users/sub-123/has-password"),
|
||||||
|
);
|
||||||
|
expect(hasPassCall).toBeDefined();
|
||||||
|
expect(hasPassCall!.options.method).toBe("GET");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updateEmail calls PATCH with primaryEmail field", async () => {
|
||||||
|
await client.updateEmail("sub-123", "new@example.com");
|
||||||
|
|
||||||
|
const patchCall = fetchCalls.find(
|
||||||
|
(c) =>
|
||||||
|
c.url.includes("/api/users/sub-123") &&
|
||||||
|
c.options.method === "PATCH" &&
|
||||||
|
!c.url.includes("password"),
|
||||||
|
);
|
||||||
|
expect(patchCall).toBeDefined();
|
||||||
|
const body = JSON.parse(patchCall!.options.body as string);
|
||||||
|
expect(body.primaryEmail).toBe("new@example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deleteUser calls DELETE on user endpoint", async () => {
|
||||||
|
await client.deleteUser("sub-123");
|
||||||
|
|
||||||
|
const deleteCall = fetchCalls.find(
|
||||||
|
(c) =>
|
||||||
|
c.url.includes("/api/users/sub-123") &&
|
||||||
|
c.options.method === "DELETE",
|
||||||
|
);
|
||||||
|
expect(deleteCall).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getUser calls GET and returns user object", async () => {
|
||||||
|
const user = await client.getUser("logto-sub-123");
|
||||||
|
expect(user.id).toBe("logto-sub-123");
|
||||||
|
expect(user.primaryEmail).toBe("user@example.com");
|
||||||
|
expect(user.name).toBe("Test User");
|
||||||
|
|
||||||
|
const getCall = fetchCalls.find(
|
||||||
|
(c) =>
|
||||||
|
c.url.includes("/api/users/logto-sub-123") &&
|
||||||
|
c.options.method === "GET" &&
|
||||||
|
!c.url.includes("has-password"),
|
||||||
|
);
|
||||||
|
expect(getCall).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws when M2M not configured", () => {
|
||||||
|
const unconfiguredClient = new LogtoManagementClient();
|
||||||
|
expect(unconfiguredClient.getAccessToken()).rejects.toThrow(
|
||||||
|
"Logto M2M not configured",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("strips /oidc suffix from issuer for base URL", async () => {
|
||||||
|
await client.getUser("test-sub");
|
||||||
|
|
||||||
|
// Token call should go to https://logto.example.com/oidc/token
|
||||||
|
const tokenCall = fetchCalls.find((c) => c.url.includes("/oidc/token"));
|
||||||
|
expect(tokenCall!.url).toBe(
|
||||||
|
"https://logto.example.com/oidc/token",
|
||||||
|
);
|
||||||
|
|
||||||
|
// API call should go to https://logto.example.com/api/users/test-sub
|
||||||
|
const apiCall = fetchCalls.find(
|
||||||
|
(c) => c.url.includes("/api/users/test-sub") && c.options.method === "GET",
|
||||||
|
);
|
||||||
|
expect(apiCall!.url).toBe(
|
||||||
|
"https://logto.example.com/api/users/test-sub",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user