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();
|
||||
Reference in New Issue
Block a user