feat(makelore): add platform oidc login
This commit is contained in:
1 parent
87d3f03d75
commit
7236def07d
12 files changed
+528
-10
No files matched your search
@@ -27,6 +27,7 @@ import {
|
||||
getRememberedPasswordState,
|
||||
updateRememberedPassword,
|
||||
} from '../../services/remembered-password';
|
||||
import { startPlatformAuthorization } from '../../services/platform-auth';
|
||||
|
||||
type PasswordLoginInput = {
|
||||
username?: unknown;
|
||||
@@ -335,6 +336,22 @@ async function handlePasswordLogin(
|
||||
});
|
||||
}
|
||||
|
||||
async function handlePlatformAuthorizationStart(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
): Promise<void> {
|
||||
// Keep the route body intentionally empty: the Main process owns the OIDC
|
||||
// client configuration and all PKCE state. The renderer only receives a
|
||||
// browser URL and never handles a client secret.
|
||||
readExactJsonObject(await parseJsonBody<Record<string, unknown>>(req), []);
|
||||
const authorization = await startPlatformAuthorization();
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
requestId: authorization.requestId,
|
||||
authorizationUrl: authorization.authorizationUrl,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRememberedPassword(res: ServerResponse): Promise<void> {
|
||||
const state = await getRememberedPasswordState();
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
@@ -791,6 +808,11 @@ export async function handleAuthRoutes(
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/auth/platform/start' && req.method === 'POST') {
|
||||
await handlePlatformAuthorizationStart(req, res);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/auth/remembered-password' && req.method === 'GET') {
|
||||
await handleRememberedPassword(res);
|
||||
return true;
|
||||
|
||||
@@ -2,8 +2,11 @@ export const NIANCODE_APP_PROTOCOL = 'niancode';
|
||||
|
||||
export type NianCodeDeepLink = {
|
||||
type: 'desktop-auth-callback';
|
||||
requestId: string;
|
||||
/** Legacy desktop auth callbacks carry a request id. OIDC callbacks use state. */
|
||||
requestId?: string;
|
||||
url: string;
|
||||
code?: string;
|
||||
state?: string;
|
||||
} | { type: 'cloud-agent'; slug: string; url: string }
|
||||
| { type: 'teacher-preview'; draftRevision: number; url: string };
|
||||
|
||||
@@ -29,6 +32,7 @@ const SENSITIVE_QUERY_KEYS = new Set([
|
||||
'device_secret',
|
||||
'token',
|
||||
]);
|
||||
const AUTH_CALLBACK_QUERY_KEYS = new Set(['request_id', 'code', 'state']);
|
||||
|
||||
export function parseNianCodeDeepLinkUrl(rawUrl: string): NianCodeDeepLink | null {
|
||||
let url: URL;
|
||||
@@ -67,15 +71,27 @@ export function parseNianCodeDeepLinkUrl(rawUrl: string): NianCodeDeepLink | nul
|
||||
}
|
||||
}
|
||||
|
||||
const requestId = url.searchParams.get('request_id')?.trim();
|
||||
if (!requestId || requestId.length > 128) {
|
||||
if ([...url.searchParams.keys()].some((key) => !AUTH_CALLBACK_QUERY_KEYS.has(key))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const requestId = url.searchParams.get('request_id')?.trim() || undefined;
|
||||
if (requestId && requestId.length > 128) return null;
|
||||
|
||||
const code = url.searchParams.get('code')?.trim() || undefined;
|
||||
const state = url.searchParams.get('state')?.trim() || undefined;
|
||||
if (Boolean(code) !== Boolean(state) || (code && code.length > 4096) || (state && state.length > 512)) {
|
||||
return null;
|
||||
}
|
||||
// The platform OIDC redirect is registered as niancode://auth/callback and
|
||||
// therefore returns code/state without the legacy request_id parameter.
|
||||
if (!requestId && !(code && state)) return null;
|
||||
|
||||
return {
|
||||
type: 'desktop-auth-callback',
|
||||
requestId,
|
||||
...(requestId ? { requestId } : {}),
|
||||
url: rawUrl,
|
||||
...(code ? { code, state } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+60
-2
@@ -65,6 +65,7 @@ import { createReleaseUtilityPreparer } from '../services/release-utility-proces
|
||||
import { createStaticArtifactSnapshot } from '../services/static-release-server';
|
||||
import {
|
||||
consumeWorksSquareStartupRuntimeCleanupRequired,
|
||||
commitWorksSquareSessionFromTokenPayload,
|
||||
getWorksSquareSessionRestoreStatus,
|
||||
getWorksSquareSessionSnapshot,
|
||||
initializeWorksSquareSession,
|
||||
@@ -73,7 +74,11 @@ import {
|
||||
} from '../services/works-square-session';
|
||||
import { shouldUseSecureWorksSquareSessionPersistence } from '../services/works-square-session-persistence-policy';
|
||||
import { initializeRememberedPassword } from '../services/remembered-password';
|
||||
import { clearManagedWorksSquareRuntimeBestEffort } from '../services/works-square-runtime';
|
||||
import {
|
||||
clearManagedWorksSquareRuntimeBestEffort,
|
||||
ensureManagedWorksSquareRuntimeClean,
|
||||
} from '../services/works-square-runtime';
|
||||
import { completePlatformAuthorization } from '../services/platform-auth';
|
||||
import { WorksSquareDesignWorkspace } from '../image-workspace/works-square-workspace';
|
||||
import type { DesignWorkspaceModule } from '../image-workspace/module';
|
||||
import {
|
||||
@@ -207,6 +212,12 @@ let releaseJobs: ReleaseJobManager | null = null;
|
||||
let codingProducts: CodingProductComposition | null = null;
|
||||
let windowIpcBindings: ReturnType<typeof registerIpcHandlers> | null = null;
|
||||
let unsubscribeAuthSession: (() => void) | null = null;
|
||||
let platformAuthReady = false;
|
||||
let queuedPlatformAuthCallback: {
|
||||
requestId: string | null;
|
||||
code: string;
|
||||
state: string;
|
||||
} | null = null;
|
||||
const mainWindowFocusState = createMainWindowFocusState();
|
||||
const quitLifecycleState = createQuitLifecycleState();
|
||||
const launchDeepLinkUrl = findNianCodeDeepLinkUrl(process.argv);
|
||||
@@ -365,6 +376,33 @@ function requestMainWindowFocus(reason: string): void {
|
||||
logger.debug(`Main window is not ready yet; deferring focus for ${reason}`);
|
||||
}
|
||||
|
||||
function completePlatformAuthCallback(callback: {
|
||||
requestId: string | null;
|
||||
code: string;
|
||||
state: string;
|
||||
}): void {
|
||||
void (async () => {
|
||||
// Match the password/mobile login path: clean the previous account's
|
||||
// derived runtime before making the new account active. Keeping the
|
||||
// authorization pending until cleanup succeeds lets the user retry after
|
||||
// a transient local cleanup failure.
|
||||
await ensureManagedWorksSquareRuntimeClean({
|
||||
codingProducts: codingProducts ?? undefined,
|
||||
imageWorkspace: imageWorkspaceModule ?? undefined,
|
||||
});
|
||||
const payload = await completePlatformAuthorization(
|
||||
callback.requestId,
|
||||
callback.code,
|
||||
callback.state,
|
||||
);
|
||||
await commitWorksSquareSessionFromTokenPayload(payload);
|
||||
requestMainWindowFocus('platform OIDC callback');
|
||||
})().catch((error) => {
|
||||
logger.warn('[auth] Platform OIDC callback was not completed', error);
|
||||
requestMainWindowFocus('platform OIDC callback failure');
|
||||
});
|
||||
}
|
||||
|
||||
function handleAppDeepLinkActivation(rawUrl: string): boolean {
|
||||
const deepLink = parseNianCodeDeepLinkUrl(rawUrl);
|
||||
if (!deepLink) {
|
||||
@@ -378,7 +416,21 @@ function handleAppDeepLinkActivation(rawUrl: string): boolean {
|
||||
queueTeacherPreviewRevision(deepLink.draftRevision);
|
||||
mainWindow?.webContents.send('navigate', '/coding-teacher-preview?draftRevision=' + deepLink.draftRevision);
|
||||
} else {
|
||||
logger.info(`Received Makelore app link: type=${deepLink.type}, request_id=${deepLink.requestId}`);
|
||||
if (deepLink.code && deepLink.state) {
|
||||
const callback = {
|
||||
requestId: deepLink.requestId ?? null,
|
||||
code: deepLink.code,
|
||||
state: deepLink.state,
|
||||
};
|
||||
if (platformAuthReady) {
|
||||
completePlatformAuthCallback(callback);
|
||||
} else {
|
||||
queuedPlatformAuthCallback = callback;
|
||||
logger.debug('[auth] Queued platform OIDC callback until session restore is ready');
|
||||
}
|
||||
} else {
|
||||
logger.info(`Received Makelore app link: type=${deepLink.type}, request_id=${deepLink.requestId ?? 'none'}`);
|
||||
}
|
||||
}
|
||||
requestMainWindowFocus('app deep link');
|
||||
return true;
|
||||
@@ -664,6 +716,12 @@ async function initialize(): Promise<void> {
|
||||
}, 'expired persisted session during startup');
|
||||
});
|
||||
}
|
||||
platformAuthReady = true;
|
||||
if (queuedPlatformAuthCallback) {
|
||||
const callback = queuedPlatformAuthCallback;
|
||||
queuedPlatformAuthCallback = null;
|
||||
completePlatformAuthCallback(callback);
|
||||
}
|
||||
if (!isE2EMode) {
|
||||
projectProgressSync = createProjectProgressSync(codingProjectStore);
|
||||
const activateProgrammingServices = (): void => {
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||
import { WORKS_SQUARE_CONFIG } from '../api/works-config';
|
||||
import { proxyAwareFetch } from '../utils/proxy-fetch';
|
||||
import type { WorksSquareTokenPayload } from './works-square-session';
|
||||
|
||||
const DEFAULT_CLIENT_ID = 'makelore';
|
||||
const DEFAULT_SCOPE = 'openid profile phone offline_access';
|
||||
const REDIRECT_URI = 'niancode://auth/callback';
|
||||
const STATE_TTL_MS = 10 * 60_000;
|
||||
|
||||
type PlatformAuthMetadata = {
|
||||
authorization_endpoint: string;
|
||||
token_endpoint: string;
|
||||
};
|
||||
|
||||
type PendingAuthorization = {
|
||||
state: string;
|
||||
codeVerifier: string;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
const pendingAuthorizations = new Map<string, PendingAuthorization>();
|
||||
let metadataPromise: Promise<PlatformAuthMetadata> | null = null;
|
||||
|
||||
function configuredIssuer(): string {
|
||||
const raw = (process.env.PLATFORM_AUTH_ISSUER || WORKS_SQUARE_CONFIG.apiBaseUrl).trim().replace(/\/+$/, '');
|
||||
const parsed = new URL(raw);
|
||||
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash) {
|
||||
throw new Error('PLATFORM_AUTH_ISSUER must be an absolute HTTP(S) URL without credentials or query parameters');
|
||||
}
|
||||
return parsed.toString().replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function configuredClientId(): string {
|
||||
return process.env.PLATFORM_AUTH_CLIENT_ID?.trim() || DEFAULT_CLIENT_ID;
|
||||
}
|
||||
|
||||
function configuredScope(): string {
|
||||
return process.env.PLATFORM_AUTH_SCOPE?.trim() || DEFAULT_SCOPE;
|
||||
}
|
||||
|
||||
function absoluteHttpUrl(value: unknown, field: string): string {
|
||||
if (typeof value !== 'string' || !value.trim()) throw new Error(`OIDC discovery missing ${field}`);
|
||||
const parsed = new URL(value);
|
||||
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) {
|
||||
throw new Error(`OIDC discovery returned an invalid ${field}`);
|
||||
}
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
async function loadMetadata(): Promise<PlatformAuthMetadata> {
|
||||
const issuer = configuredIssuer();
|
||||
const response = await proxyAwareFetch(`${issuer}/.well-known/openid-configuration`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) throw new Error(`OIDC discovery failed (${response.status})`);
|
||||
const payload = await response.json() as Record<string, unknown>;
|
||||
return {
|
||||
authorization_endpoint: absoluteHttpUrl(payload.authorization_endpoint, 'authorization_endpoint'),
|
||||
token_endpoint: absoluteHttpUrl(payload.token_endpoint, 'token_endpoint'),
|
||||
};
|
||||
}
|
||||
|
||||
async function getMetadata(): Promise<PlatformAuthMetadata> {
|
||||
if (!metadataPromise) {
|
||||
metadataPromise = loadMetadata().catch((error) => {
|
||||
metadataPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return metadataPromise;
|
||||
}
|
||||
|
||||
function takePendingAuthorization(
|
||||
requestId: string | null,
|
||||
returnedState: string,
|
||||
): PendingAuthorization | null {
|
||||
if (requestId) {
|
||||
const pending = pendingAuthorizations.get(requestId);
|
||||
pendingAuthorizations.delete(requestId);
|
||||
return pending ?? null;
|
||||
}
|
||||
|
||||
// The platform redirect is a fixed niancode:// URI and does not append our
|
||||
// local request id. The state value is the OIDC correlation handle instead.
|
||||
for (const [candidateId, pending] of pendingAuthorizations) {
|
||||
if (pending.state === returnedState) {
|
||||
pendingAuthorizations.delete(candidateId);
|
||||
return pending;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function cleanupPendingAuthorizations(now = Date.now()): void {
|
||||
for (const [requestId, pending] of pendingAuthorizations) {
|
||||
if (pending.expiresAt <= now) pendingAuthorizations.delete(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
function codeChallengeS256(verifier: string): string {
|
||||
return createHash('sha256').update(verifier, 'ascii').digest('base64url');
|
||||
}
|
||||
|
||||
function readTokenPayload(value: unknown): WorksSquareTokenPayload {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('OIDC token endpoint returned an invalid response');
|
||||
}
|
||||
const payload = value as WorksSquareTokenPayload;
|
||||
if (typeof payload.access_token !== 'string' || !payload.access_token.trim()) {
|
||||
throw new Error('OIDC token endpoint did not return access_token');
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function startPlatformAuthorization(): Promise<{
|
||||
requestId: string;
|
||||
authorizationUrl: string;
|
||||
}> {
|
||||
cleanupPendingAuthorizations();
|
||||
const metadata = await getMetadata();
|
||||
const requestId = randomUUID();
|
||||
const state = randomBytes(32).toString('base64url');
|
||||
const codeVerifier = randomBytes(64).toString('base64url');
|
||||
pendingAuthorizations.set(requestId, {
|
||||
state,
|
||||
codeVerifier,
|
||||
expiresAt: Date.now() + STATE_TTL_MS,
|
||||
});
|
||||
|
||||
const url = new URL(metadata.authorization_endpoint);
|
||||
url.searchParams.set('client_id', configuredClientId());
|
||||
url.searchParams.set('response_type', 'code');
|
||||
url.searchParams.set('redirect_uri', REDIRECT_URI);
|
||||
url.searchParams.set('scope', configuredScope());
|
||||
url.searchParams.set('state', state);
|
||||
url.searchParams.set('nonce', randomBytes(32).toString('base64url'));
|
||||
url.searchParams.set('code_challenge', codeChallengeS256(codeVerifier));
|
||||
url.searchParams.set('code_challenge_method', 'S256');
|
||||
|
||||
return { requestId, authorizationUrl: url.toString() };
|
||||
}
|
||||
|
||||
export async function completePlatformAuthorization(
|
||||
requestId: string | null,
|
||||
code: string,
|
||||
returnedState: string,
|
||||
): Promise<WorksSquareTokenPayload> {
|
||||
cleanupPendingAuthorizations();
|
||||
const pending = takePendingAuthorization(requestId, returnedState);
|
||||
if (!pending || pending.expiresAt <= Date.now()) throw new Error('OIDC authorization request expired');
|
||||
if (pending.state !== returnedState) throw new Error('OIDC authorization state mismatch');
|
||||
|
||||
const metadata = await getMetadata();
|
||||
const response = await proxyAwareFetch(metadata.token_endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
client_id: configuredClientId(),
|
||||
code_verifier: pending.codeVerifier,
|
||||
}).toString(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const detail = await response.text();
|
||||
throw new Error(`OIDC token exchange failed (${response.status}): ${detail.slice(0, 180)}`);
|
||||
}
|
||||
return readTokenPayload(await response.json());
|
||||
}
|
||||
|
||||
export function resetPlatformAuthorizationForTests(): void {
|
||||
pendingAuthorizations.clear();
|
||||
metadataPromise = null;
|
||||
}
|
||||
|
||||
export const PLATFORM_AUTH_REDIRECT_URI = REDIRECT_URI;
|
||||
Reference in new issue
Block a user