Files
nocobase-mcp/src/auth-service.ts
T

192 lines
6.2 KiB
TypeScript

import { existsSync } from "node:fs";
import {
credentialsPath,
loadStoredCredentials,
NOCOBASE_API_BASE_URL,
saveOAuthCredentials,
} from "./credentials.js";
import { NocoBaseClient } from "./nocobase-client.js";
import {
type BrowserLoginOptions,
type BrowserLoginSession,
type OAuthCredentials,
startBrowserLogin,
} from "./oauth.js";
export type AuthPayload = Record<string, unknown>;
export interface AuthService {
start(options?: { force?: boolean }): Promise<AuthPayload>;
status(): Promise<AuthPayload>;
}
export interface NocoBaseAuthServiceOptions {
credentialsFile?: string;
now?: () => number;
openBrowser?: BrowserLoginOptions["openBrowser"];
startLogin?: (options: BrowserLoginOptions) => Promise<BrowserLoginSession>;
verifyCredentials?: (credentials: OAuthCredentials) => Promise<void>;
onCredentialsSaved?: () => void;
}
interface PendingAuthorization {
kind: "pending";
prompt: BrowserLoginSession["prompt"];
expiresAt: number;
}
interface FailedAuthorization {
kind: "failed";
message: string;
}
interface AuthenticatedAuthorization {
kind: "authenticated";
}
type AuthorizationState = PendingAuthorization | FailedAuthorization | AuthenticatedAuthorization;
export class NocoBaseAuthService implements AuthService {
private readonly credentialsFile: string;
private readonly now: () => number;
private readonly openBrowser?: BrowserLoginOptions["openBrowser"];
private readonly startLogin: (options: BrowserLoginOptions) => Promise<BrowserLoginSession>;
private readonly verifyCredentials: (credentials: OAuthCredentials) => Promise<void>;
private readonly onCredentialsSaved?: () => void;
private state?: AuthorizationState;
private startInFlight?: Promise<AuthPayload>;
constructor(options: NocoBaseAuthServiceOptions = {}) {
this.credentialsFile = options.credentialsFile ?? credentialsPath();
this.now = options.now ?? Date.now;
this.openBrowser = options.openBrowser;
this.startLogin = options.startLogin ?? startBrowserLogin;
this.verifyCredentials = options.verifyCredentials ?? verifyOAuthCredentials;
this.onCredentialsSaved = options.onCredentialsSaved;
}
async start(options: { force?: boolean } = {}): Promise<AuthPayload> {
if (this.startInFlight) return await this.startInFlight;
const pending = this.startInternal(options.force ?? false);
this.startInFlight = pending;
try {
return await pending;
} finally {
if (this.startInFlight === pending) this.startInFlight = undefined;
}
}
async status(): Promise<AuthPayload> {
if (this.state?.kind === "pending") return this.pendingPayload(this.state);
if (this.state?.kind === "failed") {
return {
status: "authorization_failed",
error: {
code: "nocobase_authorization_failed",
message: this.state.message,
retryable: true,
},
next_action: "Call nocobase_login again to create a new browser authorization link.",
};
}
if (this.state?.kind === "authenticated" || this.storedOAuthClientId() !== undefined) {
return authenticatedPayload();
}
if (this.hasStoredPersonalKey()) return authenticatedPayload();
return {
status: "login_required",
next_action:
"Call nocobase_login, send its login_url and user_code to the user, then poll nocobase_login_status.",
};
}
private async startInternal(force: boolean): Promise<AuthPayload> {
if (this.state?.kind === "pending") return this.pendingPayload(this.state);
if (!force) {
const current = await this.status();
if (current.status === "authenticated") return current;
}
const session = await this.startLogin({
clientId: this.storedOAuthClientId(),
openBrowser: this.openBrowser,
sleep: backgroundSleep,
});
const pending: PendingAuthorization = {
kind: "pending",
prompt: session.prompt,
expiresAt: this.now() + session.prompt.expiresIn * 1_000,
};
this.state = pending;
void session.credentials
.then(async (credentials) => {
await this.verifyCredentials(credentials);
saveOAuthCredentials(credentials, this.credentialsFile);
this.onCredentialsSaved?.();
if (this.state === pending) this.state = { kind: "authenticated" };
})
.catch((error: unknown) => {
if (this.state === pending) {
this.state = {
kind: "failed",
message: error instanceof Error ? error.message : String(error),
};
}
});
return this.pendingPayload(pending);
}
private pendingPayload(state: PendingAuthorization): AuthPayload {
return {
status: "authorization_pending",
login_url: state.prompt.url,
user_code: state.prompt.userCode,
expires_in_seconds: Math.max(0, Math.ceil((state.expiresAt - this.now()) / 1_000)),
browser_opened: state.prompt.opened,
next_action:
"Send login_url and user_code to the user. After approval, poll nocobase_login_status until authenticated.",
};
}
private storedOAuthClientId(): string | undefined {
if (!existsSync(this.credentialsFile)) return undefined;
try {
const stored = loadStoredCredentials(this.credentialsFile);
return stored.type === "oauth" ? stored.clientId : undefined;
} catch {
return undefined;
}
}
private hasStoredPersonalKey(): boolean {
if (!existsSync(this.credentialsFile)) return false;
try {
return loadStoredCredentials(this.credentialsFile).type === "personal-authorize-key";
} catch {
return false;
}
}
}
async function verifyOAuthCredentials(credentials: OAuthCredentials): Promise<void> {
const client = new NocoBaseClient(() => ({
apiBaseUrl: NOCOBASE_API_BASE_URL,
authorizeKey: credentials.accessToken,
}));
await client.verifyCredentials();
}
async function backgroundSleep(milliseconds: number): Promise<void> {
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, milliseconds);
timer.unref?.();
});
}
function authenticatedPayload(): AuthPayload {
return {
status: "authenticated",
next_action: "Call crm_health, then continue with the requested CRM operation.",
};
}