192 lines
6.2 KiB
TypeScript
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.",
|
|
};
|
|
}
|