[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
759
infrastructure/services/adapters/OneDriveAdapter.ts
Normal file
759
infrastructure/services/adapters/OneDriveAdapter.ts
Normal file
@@ -0,0 +1,759 @@
|
||||
/**
|
||||
* OneDrive OAuth Adapter - PKCE Loopback Flow with MSAL
|
||||
*
|
||||
* Uses MSAL-style Authorization Code Grant with PKCE.
|
||||
* Data is stored in the app's special folder.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Generate PKCE challenge
|
||||
* 2. Open browser with auth URL
|
||||
* 3. User authorizes, redirected to loopback
|
||||
* 4. Exchange code for tokens
|
||||
* 5. Use Graph API to manage sync file
|
||||
*/
|
||||
|
||||
import {
|
||||
SYNC_CONSTANTS,
|
||||
ONEDRIVE_REAUTH_REQUIRED_MARKER,
|
||||
isOneDriveReauthRequiredMessage,
|
||||
type OAuthTokens,
|
||||
type ProviderAccount,
|
||||
type SyncedFile,
|
||||
type PKCEChallenge,
|
||||
} from '../../../domain/sync';
|
||||
import { netcattyBridge } from '../netcattyBridge';
|
||||
import { arrayBufferToBase64, generateRandomBytes } from '../EncryptionService';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface OneDriveUserInfo {
|
||||
id: string;
|
||||
displayName: string;
|
||||
mail?: string;
|
||||
userPrincipalName: string;
|
||||
}
|
||||
|
||||
export interface DriveItem {
|
||||
id: string;
|
||||
name: string;
|
||||
lastModifiedDateTime: string;
|
||||
size?: number;
|
||||
'@microsoft.graph.downloadUrl'?: string;
|
||||
}
|
||||
|
||||
const ONEDRIVE_SCOPES = [
|
||||
'https://graph.microsoft.com/Files.ReadWrite.AppFolder',
|
||||
'https://graph.microsoft.com/User.Read',
|
||||
'offline_access',
|
||||
];
|
||||
|
||||
const ONEDRIVE_SCOPE = ONEDRIVE_SCOPES.join(' ');
|
||||
|
||||
/**
|
||||
* Raised when the OneDrive refresh token can no longer be exchanged for a new
|
||||
* access token (expired / revoked / consent withdrawn). The user must
|
||||
* re-authorize; silent refresh cannot recover. CloudSyncManager detects this to
|
||||
* surface a clear "reconnect" state instead of a raw error.
|
||||
*
|
||||
* The message always carries ONEDRIVE_REAUTH_REQUIRED_MARKER so the condition is
|
||||
* still detectable after the error is re-wrapped (e.g. `new Error(String(err))`)
|
||||
* as it bubbles through the provider-agnostic sync pipeline.
|
||||
*/
|
||||
export class OneDriveReauthRequiredError extends Error {
|
||||
constructor(message = 'OneDrive session expired, please reconnect.') {
|
||||
super(
|
||||
isOneDriveReauthRequiredMessage(message)
|
||||
? message
|
||||
: `${ONEDRIVE_REAUTH_REQUIRED_MARKER}: ${message}`
|
||||
);
|
||||
this.name = 'OneDriveReauthRequiredError';
|
||||
}
|
||||
}
|
||||
|
||||
export const isOneDriveReauthRequiredError = (error: unknown): boolean => {
|
||||
if (error instanceof OneDriveReauthRequiredError) {
|
||||
return true;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return isOneDriveReauthRequiredMessage(message);
|
||||
};
|
||||
|
||||
const isUnauthorizedError = (error: unknown): boolean => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const lower = message.toLowerCase();
|
||||
return message.includes(' 401') ||
|
||||
message.includes('401 -') ||
|
||||
lower.includes('unauthenticated') ||
|
||||
lower.includes('invalidauthenticationtoken');
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// PKCE Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Base64 URL encoding (no padding, URL-safe chars)
|
||||
*/
|
||||
const base64UrlEncode = (bytes: Uint8Array): string => {
|
||||
const base64 = arrayBufferToBase64(bytes);
|
||||
return base64
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a cryptographically random code verifier
|
||||
*/
|
||||
const generateCodeVerifier = (): string => {
|
||||
const bytes = generateRandomBytes(32);
|
||||
return base64UrlEncode(bytes);
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate code challenge from verifier (S256)
|
||||
*/
|
||||
const generateCodeChallenge = async (verifier: string): Promise<string> => {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(verifier);
|
||||
const digest = await crypto.subtle.digest('SHA-256', data);
|
||||
return base64UrlEncode(new Uint8Array(digest));
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate PKCE challenge
|
||||
*/
|
||||
export const generatePKCEChallenge = async (): Promise<PKCEChallenge> => {
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
||||
const state = base64UrlEncode(generateRandomBytes(16));
|
||||
|
||||
return {
|
||||
codeVerifier,
|
||||
codeChallenge,
|
||||
state,
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// OAuth Flow
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Build authorization URL for OneDrive OAuth
|
||||
*/
|
||||
export const buildAuthUrl = async (
|
||||
redirectUri: string
|
||||
): Promise<{ url: string; pkce: PKCEChallenge }> => {
|
||||
const pkce = await generatePKCEChallenge();
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: SYNC_CONSTANTS.ONEDRIVE_CLIENT_ID,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
scope: ONEDRIVE_SCOPE,
|
||||
code_challenge: pkce.codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
state: pkce.state,
|
||||
response_mode: 'query',
|
||||
prompt: 'consent',
|
||||
});
|
||||
|
||||
return {
|
||||
url: `${SYNC_CONSTANTS.ONEDRIVE_AUTH_URL}?${params.toString()}`,
|
||||
pkce,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Exchange authorization code for tokens
|
||||
*/
|
||||
export const exchangeCodeForTokens = async (
|
||||
code: string,
|
||||
codeVerifier: string,
|
||||
redirectUri: string
|
||||
): Promise<OAuthTokens> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.onedriveExchangeCodeForTokens) {
|
||||
return bridge.onedriveExchangeCodeForTokens({
|
||||
clientId: SYNC_CONSTANTS.ONEDRIVE_CLIENT_ID,
|
||||
code,
|
||||
codeVerifier,
|
||||
redirectUri,
|
||||
scope: ONEDRIVE_SCOPE,
|
||||
});
|
||||
}
|
||||
const response = await fetch(SYNC_CONSTANTS.ONEDRIVE_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: SYNC_CONSTANTS.ONEDRIVE_CLIENT_ID,
|
||||
code,
|
||||
code_verifier: codeVerifier,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: redirectUri,
|
||||
scope: ONEDRIVE_SCOPE,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(`Token exchange failed: ${error.error_description || error.error}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
expiresAt: Date.now() + data.expires_in * 1000,
|
||||
tokenType: data.token_type,
|
||||
scope: data.scope,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Refresh access token
|
||||
*/
|
||||
export const refreshAccessToken = async (refreshToken: string): Promise<OAuthTokens> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.onedriveRefreshAccessToken) {
|
||||
return bridge.onedriveRefreshAccessToken({
|
||||
clientId: SYNC_CONSTANTS.ONEDRIVE_CLIENT_ID,
|
||||
refreshToken,
|
||||
scope: ONEDRIVE_SCOPE,
|
||||
});
|
||||
}
|
||||
const response = await fetch(SYNC_CONSTANTS.ONEDRIVE_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: SYNC_CONSTANTS.ONEDRIVE_CLIENT_ID,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
scope: ONEDRIVE_SCOPE,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to refresh token');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token || refreshToken,
|
||||
expiresAt: Date.now() + data.expires_in * 1000,
|
||||
tokenType: data.token_type,
|
||||
scope: data.scope,
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// User Info
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get authenticated user info
|
||||
*/
|
||||
export const getUserInfo = async (accessToken: string): Promise<ProviderAccount> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.onedriveGetUserInfo) {
|
||||
const user = await bridge.onedriveGetUserInfo({ accessToken });
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
avatarUrl: user.avatarDataUrl,
|
||||
};
|
||||
}
|
||||
const response = await fetch(`${SYNC_CONSTANTS.ONEDRIVE_GRAPH_API}/me`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get user info');
|
||||
}
|
||||
|
||||
const user: OneDriveUserInfo = await response.json();
|
||||
|
||||
// Try to get profile photo
|
||||
let avatarUrl: string | undefined;
|
||||
try {
|
||||
const photoResponse = await fetch(
|
||||
`${SYNC_CONSTANTS.ONEDRIVE_GRAPH_API}/me/photo/$value`,
|
||||
{
|
||||
headers: { 'Authorization': `Bearer ${accessToken}` },
|
||||
}
|
||||
);
|
||||
if (photoResponse.ok) {
|
||||
const blob = await photoResponse.blob();
|
||||
avatarUrl = URL.createObjectURL(blob);
|
||||
}
|
||||
} catch {
|
||||
// Photo not available
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.mail || user.userPrincipalName,
|
||||
name: user.displayName,
|
||||
avatarUrl,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate access token
|
||||
*/
|
||||
export const validateToken = async (accessToken: string): Promise<boolean> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.onedriveGetUserInfo) {
|
||||
try {
|
||||
await bridge.onedriveGetUserInfo({ accessToken });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${SYNC_CONSTANTS.ONEDRIVE_GRAPH_API}/me`, {
|
||||
headers: { 'Authorization': `Bearer ${accessToken}` },
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// OneDrive App Folder Operations
|
||||
// ============================================================================
|
||||
|
||||
const APP_FOLDER_PATH = '/drive/special/approot';
|
||||
|
||||
// Eventual-consistency retry for OneDrive "not found" lookups. The Graph API
|
||||
// can briefly 404 a file that was uploaded seconds ago from another device
|
||||
// (most commonly when the other device is syncing through the OneDrive
|
||||
// desktop client and the change has not yet reached Graph). Treating every
|
||||
// 404 as authoritative "cloud is empty" lets a second device proceed to an
|
||||
// empty-cloud upload path and overwrite real data (#779). We retry a small
|
||||
// bounded number of times with short backoff to flush through that window.
|
||||
const NOT_FOUND_RETRIES = 2;
|
||||
const NOT_FOUND_BACKOFF_MS = 1500;
|
||||
|
||||
const sleep = (ms: number): Promise<void> =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
async function retryOnNotFound<T>(
|
||||
fetchOnce: () => Promise<T | null>,
|
||||
): Promise<T | null> {
|
||||
let result = await fetchOnce();
|
||||
for (let attempt = 1; attempt <= NOT_FOUND_RETRIES && result === null; attempt++) {
|
||||
await sleep(NOT_FOUND_BACKOFF_MS * attempt);
|
||||
result = await fetchOnce();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure app folder exists and find sync file
|
||||
*/
|
||||
export const findSyncFile = async (accessToken: string): Promise<string | null> => {
|
||||
const fetchOnce = async (): Promise<string | null> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.onedriveFindSyncFile) {
|
||||
const result = await bridge.onedriveFindSyncFile({
|
||||
accessToken,
|
||||
fileName: SYNC_CONSTANTS.SYNC_FILE_NAME,
|
||||
});
|
||||
return result.fileId || null;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${SYNC_CONSTANTS.ONEDRIVE_GRAPH_API}/me${APP_FOLDER_PATH}:/${SYNC_CONSTANTS.SYNC_FILE_NAME}`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to find sync file');
|
||||
}
|
||||
|
||||
const item: DriveItem = await response.json();
|
||||
return item.id;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return retryOnNotFound(fetchOnce);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create or update sync file in app folder
|
||||
*/
|
||||
export const uploadSyncFile = async (
|
||||
accessToken: string,
|
||||
syncedFile: SyncedFile
|
||||
): Promise<string> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.onedriveUploadSyncFile) {
|
||||
const result = await bridge.onedriveUploadSyncFile({
|
||||
accessToken,
|
||||
fileName: SYNC_CONSTANTS.SYNC_FILE_NAME,
|
||||
syncedFile,
|
||||
});
|
||||
if (!result.fileId) {
|
||||
throw new Error('Failed to upload sync file');
|
||||
}
|
||||
return result.fileId;
|
||||
}
|
||||
const content = JSON.stringify(syncedFile, null, 2);
|
||||
|
||||
const response = await fetch(
|
||||
`${SYNC_CONSTANTS.ONEDRIVE_GRAPH_API}/me${APP_FOLDER_PATH}:/${SYNC_CONSTANTS.SYNC_FILE_NAME}:/content`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: content,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to upload sync file');
|
||||
}
|
||||
|
||||
const item: DriveItem = await response.json();
|
||||
return item.id;
|
||||
};
|
||||
|
||||
/**
|
||||
* Download sync file
|
||||
*/
|
||||
export const downloadSyncFile = async (
|
||||
accessToken: string,
|
||||
fileId?: string
|
||||
): Promise<SyncedFile | null> => {
|
||||
const fetchOnce = async (): Promise<SyncedFile | null> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.onedriveDownloadSyncFile) {
|
||||
const result = await bridge.onedriveDownloadSyncFile({
|
||||
accessToken,
|
||||
fileId,
|
||||
fileName: SYNC_CONSTANTS.SYNC_FILE_NAME,
|
||||
});
|
||||
return (result.syncedFile as SyncedFile | null) || null;
|
||||
}
|
||||
try {
|
||||
// Can use either file ID or path
|
||||
const url = fileId
|
||||
? `${SYNC_CONSTANTS.ONEDRIVE_GRAPH_API}/me/drive/items/${fileId}/content`
|
||||
: `${SYNC_CONSTANTS.ONEDRIVE_GRAPH_API}/me${APP_FOLDER_PATH}:/${SYNC_CONSTANTS.SYNC_FILE_NAME}:/content`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to download sync file');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return retryOnNotFound(fetchOnce);
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete sync file
|
||||
*/
|
||||
export const deleteSyncFile = async (
|
||||
accessToken: string,
|
||||
fileId: string
|
||||
): Promise<void> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.onedriveDeleteSyncFile) {
|
||||
await bridge.onedriveDeleteSyncFile({ accessToken, fileId });
|
||||
return;
|
||||
}
|
||||
const response = await fetch(
|
||||
`${SYNC_CONSTANTS.ONEDRIVE_GRAPH_API}/me/drive/items/${fileId}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok && response.status !== 404) {
|
||||
throw new Error('Failed to delete sync file');
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// OneDrive Adapter Class
|
||||
// ============================================================================
|
||||
|
||||
export class OneDriveAdapter {
|
||||
private tokens: OAuthTokens | null = null;
|
||||
private fileId: string | null = null;
|
||||
private account: ProviderAccount | null = null;
|
||||
private pkceChallenge: PKCEChallenge | null = null;
|
||||
/**
|
||||
* Invoked whenever the access token is silently refreshed. Lets the owner
|
||||
* (CloudSyncManager) persist the rotated tokens — Microsoft consumer refresh
|
||||
* tokens rotate on every refresh and invalidate the previous one, so without
|
||||
* persisting the new refresh token the stored one eventually goes stale and
|
||||
* forces the user to reconnect (#1189).
|
||||
*/
|
||||
private onTokensRefreshed: ((tokens: OAuthTokens) => void) | null = null;
|
||||
|
||||
constructor(tokens?: OAuthTokens, fileId?: string) {
|
||||
if (tokens) {
|
||||
this.tokens = tokens;
|
||||
}
|
||||
this.fileId = fileId || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback that receives refreshed tokens so the caller can
|
||||
* persist them. Passing null removes the callback.
|
||||
*/
|
||||
setOnTokensRefreshed(callback: ((tokens: OAuthTokens) => void) | null): void {
|
||||
this.onTokensRefreshed = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the access token using the supplied refresh token, store the
|
||||
* rotated tokens in-memory, and notify the persistence callback. Refresh
|
||||
* failures caused by a dead refresh token are normalized to
|
||||
* OneDriveReauthRequiredError so callers can prompt for reconnect.
|
||||
*/
|
||||
private async refreshTokens(refreshToken: string): Promise<OAuthTokens> {
|
||||
let refreshed: OAuthTokens;
|
||||
try {
|
||||
refreshed = await refreshAccessToken(refreshToken);
|
||||
} catch (error) {
|
||||
if (isOneDriveReauthRequiredError(error)) {
|
||||
// Preserve the message (it carries the marker the bridge added) so
|
||||
// downstream layers can still detect this after any re-wrapping.
|
||||
throw new OneDriveReauthRequiredError(
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
this.tokens = refreshed;
|
||||
try {
|
||||
this.onTokensRefreshed?.(refreshed);
|
||||
} catch {
|
||||
// Persistence is best-effort; a failed save must not abort the sync that
|
||||
// triggered the refresh — the fresh tokens still work for this session.
|
||||
}
|
||||
return refreshed;
|
||||
}
|
||||
|
||||
get isAuthenticated(): boolean {
|
||||
return !!this.tokens?.accessToken;
|
||||
}
|
||||
|
||||
get accountInfo(): ProviderAccount | null {
|
||||
return this.account;
|
||||
}
|
||||
|
||||
get resourceId(): string | null {
|
||||
return this.fileId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start OAuth flow - returns URL to open in browser
|
||||
*/
|
||||
async startAuth(redirectUri: string): Promise<string> {
|
||||
const { url, pkce } = await buildAuthUrl(redirectUri);
|
||||
this.pkceChallenge = pkce;
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PKCE state for verification
|
||||
*/
|
||||
getPKCEState(): string | null {
|
||||
return this.pkceChallenge?.state || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete authentication with authorization code
|
||||
*/
|
||||
async completeAuth(code: string, redirectUri: string): Promise<OAuthTokens> {
|
||||
if (!this.pkceChallenge) {
|
||||
throw new Error('No PKCE challenge - start auth first');
|
||||
}
|
||||
|
||||
this.tokens = await exchangeCodeForTokens(
|
||||
code,
|
||||
this.pkceChallenge.codeVerifier,
|
||||
redirectUri
|
||||
);
|
||||
this.pkceChallenge = null;
|
||||
|
||||
this.account = await getUserInfo(this.tokens.accessToken);
|
||||
|
||||
return this.tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set tokens from storage
|
||||
*/
|
||||
async setTokens(tokens: OAuthTokens): Promise<void> {
|
||||
this.tokens = tokens;
|
||||
|
||||
// Refresh if expired
|
||||
if (tokens.expiresAt && Date.now() > tokens.expiresAt - 60000) {
|
||||
if (tokens.refreshToken) {
|
||||
this.tokens = await this.refreshTokens(tokens.refreshToken);
|
||||
} else {
|
||||
throw new OneDriveReauthRequiredError(
|
||||
'OneDrive session expired and no refresh token is available, please reconnect.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (await validateToken(this.tokens.accessToken)) {
|
||||
this.account = await getUserInfo(this.tokens.accessToken);
|
||||
} else {
|
||||
throw new Error('Token is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure token is fresh
|
||||
*/
|
||||
private async ensureValidToken(): Promise<string> {
|
||||
if (!this.tokens) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
if (this.tokens.expiresAt && Date.now() > this.tokens.expiresAt - 60000) {
|
||||
if (this.tokens.refreshToken) {
|
||||
this.tokens = await this.refreshTokens(this.tokens.refreshToken);
|
||||
} else {
|
||||
throw new OneDriveReauthRequiredError(
|
||||
'OneDrive session expired and no refresh token is available, please reconnect.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return this.tokens.accessToken;
|
||||
}
|
||||
|
||||
private async runWithAuthRetry<T>(
|
||||
operation: (accessToken: string) => Promise<T>
|
||||
): Promise<T> {
|
||||
const accessToken = await this.ensureValidToken();
|
||||
|
||||
try {
|
||||
return await operation(accessToken);
|
||||
} catch (error) {
|
||||
if (isUnauthorizedError(error) && this.tokens?.refreshToken) {
|
||||
const refreshed = await this.refreshTokens(this.tokens.refreshToken);
|
||||
return await operation(refreshed.accessToken);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign out
|
||||
*/
|
||||
signOut(): void {
|
||||
this.tokens = null;
|
||||
this.fileId = null;
|
||||
this.account = null;
|
||||
this.pkceChallenge = null;
|
||||
this.onTokensRefreshed = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize or find sync file
|
||||
*/
|
||||
async initializeSync(): Promise<string | null> {
|
||||
return this.runWithAuthRetry(async (accessToken) => {
|
||||
this.fileId = await findSyncFile(accessToken);
|
||||
return this.fileId;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload sync file
|
||||
*/
|
||||
async upload(syncedFile: SyncedFile): Promise<string> {
|
||||
return this.runWithAuthRetry(async (accessToken) => {
|
||||
this.fileId = await uploadSyncFile(accessToken, syncedFile);
|
||||
return this.fileId;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Download sync file
|
||||
*/
|
||||
async download(): Promise<SyncedFile | null> {
|
||||
return this.runWithAuthRetry(async (accessToken) => {
|
||||
if (!this.fileId) {
|
||||
this.fileId = await findSyncFile(accessToken);
|
||||
}
|
||||
return downloadSyncFile(accessToken, this.fileId || undefined);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete sync data
|
||||
*/
|
||||
async deleteSync(): Promise<void> {
|
||||
if (!this.tokens || !this.fileId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.runWithAuthRetry(async (accessToken) => {
|
||||
await deleteSyncFile(accessToken, this.fileId as string);
|
||||
this.fileId = null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tokens for storage
|
||||
*/
|
||||
getTokens(): OAuthTokens | null {
|
||||
return this.tokens;
|
||||
}
|
||||
}
|
||||
|
||||
export default OneDriveAdapter;
|
||||
Reference in New Issue
Block a user