diff --git a/packages/@magic-ext/oauth2/package.json b/packages/@magic-ext/oauth2/package.json index bb3ef64c5..df00f6b8f 100644 --- a/packages/@magic-ext/oauth2/package.json +++ b/packages/@magic-ext/oauth2/package.json @@ -25,9 +25,13 @@ "externals": { "include": [ "@magic-sdk/provider" + ], + "exclude": [ + "@datadog/browser-logs" ] }, "dependencies": { + "@datadog/browser-logs": "^5.0.0", "crypto-js": "^4.2.0" }, "devDependencies": { diff --git a/packages/@magic-ext/oauth2/src/index.ts b/packages/@magic-ext/oauth2/src/index.ts index 4f25a7212..0fca9fb87 100644 --- a/packages/@magic-ext/oauth2/src/index.ts +++ b/packages/@magic-ext/oauth2/src/index.ts @@ -18,6 +18,8 @@ import { OAuthGetResultEventHandlers, } from '@magic-sdk/types'; import { createCryptoChallenge } from './crypto'; +import { storageWrite, storageRead, storageRemove } from './storage'; +import { logger } from './logger'; const PKCE_STORAGE_KEY = 'magic_oauth_pkce_verifier'; @@ -76,9 +78,19 @@ export class OAuthExtension extends Extension.Internal<'oauth2'> { if (successResult?.pkceMetadata) { // New path: store codeVerifier + all OAuth metadata at the SDK (parent page) level. - // sessionStorage persists across same-tab redirects but never enters the iframe. - sessionStorage.setItem(PKCE_STORAGE_KEY, JSON.stringify({ codeVerifier, ...successResult.pkceMetadata })); - localStorage.setItem(PKCE_STORAGE_KEY, JSON.stringify({ codeVerifier, ...successResult.pkceMetadata })); + // Written to sessionStorage, localStorage, and IndexedDB for maximum durability. + const writeResult = await storageWrite( + PKCE_STORAGE_KEY, + JSON.stringify({ codeVerifier, ...successResult.pkceMetadata }), + ); + + const logPayload = { + pkce: { + provider: configuration.provider, + storageLayers: writeResult, + }, + }; + logger.info('oauth2.pkce.stored', logPayload); } if (successResult?.oauthAuthoriationURI) { @@ -199,21 +211,15 @@ export class OAuthExtension extends Extension.Internal<'oauth2'> { private getResult(configuration: OAuthVerificationConfiguration, queryString: string) { const { showMfaModal } = configuration; - const { hasStateMismatch, clientMetadata } = this.retrievePKCEMetadata(queryString); - - const requestPayload = this.utils.createJsonRpcRequestPayload(OAuthPayloadMethods.Verify, [ - { - authorizationResponseParams: queryString, - magicApiKey: this.sdk.apiKey, - platform: 'web', - showUI: showMfaModal, - ...configuration, - ...(clientMetadata ? { clientMetadata } : {}), - }, - ]); + // requestPayload is assigned inside the async callback once PKCE metadata is retrieved. + // It is only accessed by the MFA intermediary closures below, which cannot fire until + // the server sends an MFA challenge — always after requestPayload has been assigned. + let requestPayload: ReturnType; const promiEvent = this.utils.createPromiEvent( async (resolve, reject) => { + const { hasStateMismatch, clientMetadata } = await this.retrievePKCEMetadata(queryString); + if (!clientMetadata) { return reject( this.createError( @@ -234,6 +240,17 @@ export class OAuthExtension extends Extension.Internal<'oauth2'> { ); } + requestPayload = this.utils.createJsonRpcRequestPayload(OAuthPayloadMethods.Verify, [ + { + authorizationResponseParams: queryString, + magicApiKey: this.sdk.apiKey, + platform: 'web', + showUI: showMfaModal, + ...configuration, + clientMetadata, + }, + ]); + const getResultRequest = this.request( requestPayload, ); @@ -318,25 +335,36 @@ export class OAuthExtension extends Extension.Internal<'oauth2'> { } } - private retrievePKCEMetadata(queryString: string): { + private async retrievePKCEMetadata(queryString: string): Promise<{ clientMetadata?: Record; hasStateMismatch: boolean; - } { + }> { let hasStateMismatch = false; // Retrieve and immediately clear the full PKCE metadata stored at SDK level. - const storedInSession = sessionStorage.getItem(PKCE_STORAGE_KEY); - const storedInLocal = localStorage.getItem(PKCE_STORAGE_KEY); - sessionStorage.removeItem(PKCE_STORAGE_KEY); - localStorage.removeItem(PKCE_STORAGE_KEY); + // Reads from sessionStorage → localStorage → IndexedDB (first non-null wins). + const { value: stored, source: storageSource } = await storageRead(PKCE_STORAGE_KEY); + await storageRemove(PKCE_STORAGE_KEY); // clientMetadata contains { codeVerifier, state, redirectUri, appID, provider }. // Forwarding it lets the embedded-wallet verify handler skip its iframe storage entirely. // When absent (old embedded-wallet path), the handler falls back to its stored metadata. - const clientMetadata = storedInSession - ? (JSON.parse(storedInSession) as Record) - : storedInLocal - ? (JSON.parse(storedInLocal) as Record) - : undefined; + const clientMetadata = stored ? (JSON.parse(stored) as Record) : undefined; + + if (!clientMetadata) { + logger.error('oauth2.pkce.missing', { + pkce: { + storageSource, + referrer: typeof document !== 'undefined' ? document.referrer : undefined, + }, + }); + } else { + logger.info('oauth2.pkce.retrieved', { + pkce: { + storageSource, + provider: clientMetadata.provider, + }, + }); + } // State verification for the new PKCE path. // The extension generated the state, so it verifies it here — before any RPC call — as CSRF protection. @@ -345,6 +373,13 @@ export class OAuthExtension extends Extension.Internal<'oauth2'> { const returnedState = new URLSearchParams(queryString).get('state'); if (!returnedState || returnedState !== clientMetadata.state) { hasStateMismatch = true; + logger.error('oauth2.pkce.state_mismatch', { + pkce: { + storageSource, + provider: clientMetadata.provider, + hasReturnedState: !!returnedState, + }, + }); } } diff --git a/packages/@magic-ext/oauth2/src/logger.ts b/packages/@magic-ext/oauth2/src/logger.ts new file mode 100644 index 000000000..3bfdecffc --- /dev/null +++ b/packages/@magic-ext/oauth2/src/logger.ts @@ -0,0 +1,30 @@ +/** + * Datadog browser-logs logger for the oauth2 extension. + * + * Uses the official @datadog/browser-logs SDK, which is bundled into the extension + * output (excluded from externals). The client token is browser-safe (write-only). + * The SDK automatically captures network.client.ip server-side from the request source IP. + */ + +import { datadogLogs } from '@datadog/browser-logs'; + +const DATADOG_CLIENT_TOKEN = 'pub6843da41b336b49cfed0626f60a8ff68'; +const SERVICE = 'magic-oauth2-extension'; + +datadogLogs.init({ + clientToken: DATADOG_CLIENT_TOKEN, + site: 'datadoghq.com', + service: SERVICE, + sessionSampleRate: 100, + forwardErrorsToLogs: false, + usePartitionedCrossSiteSessionCookie: true, + useSecureSessionCookie: true, +}); + +export type LogContext = Record; + +export const logger = { + info: (message: string, context?: LogContext) => datadogLogs.logger.info(message, context), + warn: (message: string, context?: LogContext) => datadogLogs.logger.warn(message, context), + error: (message: string, context?: LogContext) => datadogLogs.logger.error(message, context), +}; diff --git a/packages/@magic-ext/oauth2/src/storage.ts b/packages/@magic-ext/oauth2/src/storage.ts new file mode 100644 index 000000000..884e14df9 --- /dev/null +++ b/packages/@magic-ext/oauth2/src/storage.ts @@ -0,0 +1,217 @@ +import { logger } from './logger'; + +const IDB_DB_NAME = 'magic_oauth_db'; +const IDB_DB_VERSION = 1; +const IDB_STORE_NAME = 'pkce_store'; + +// Cookie TTL covers the OAuth round-trip with margin. Short enough to limit exposure. +const COOKIE_MAX_AGE_SECONDS = 600; // 10 minutes + +/** + * Cookies are the most iOS ITP-resilient storage primitive for PKCE data: + * - ITP only restricts *third-party* cookies. A cookie set on the app's own origin + * is first-party and is never touched by ITP. + * - SameSite=Lax allows the cookie to be sent on the top-level GET navigation that + * returns from the OAuth provider — the exact redirect we need to survive. + * - Cookies survive the ASWebAuthenticationSession ↔ WKWebView process boundary, + * whereas sessionStorage, localStorage, and IndexedDB do not cross that boundary. + */ +function cookieWrite(key: string, value: string): void { + const encoded = encodeURIComponent(value); + document.cookie = `${key}=${encoded}; SameSite=Lax; Secure; Path=/; Max-Age=${COOKIE_MAX_AGE_SECONDS}`; +} + +function cookieRead(key: string): string | null { + const prefix = `${key}=`; + const match = document.cookie.split('; ').find((row) => row.startsWith(prefix)); + return match ? decodeURIComponent(match.slice(prefix.length)) : null; +} + +function cookieRemove(key: string): void { + document.cookie = `${key}=; SameSite=Lax; Secure; Path=/; Max-Age=0`; +} + +function openDB(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(IDB_DB_NAME, IDB_DB_VERSION); + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains(IDB_STORE_NAME)) { + db.createObjectStore(IDB_STORE_NAME); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +// These helpers intentionally throw — callers handle errors so they can log with context. + +async function idbWrite(key: string, value: string): Promise { + const db = await openDB(); + await new Promise((resolve, reject) => { + const tx = db.transaction(IDB_STORE_NAME, 'readwrite'); + tx.objectStore(IDB_STORE_NAME).put(value, key); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +} + +async function idbRead(key: string): Promise { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(IDB_STORE_NAME, 'readonly'); + const request = tx.objectStore(IDB_STORE_NAME).get(key); + request.onsuccess = () => resolve((request.result as string) ?? null); + request.onerror = () => reject(request.error); + }); +} + +async function idbRemove(key: string): Promise { + const db = await openDB(); + await new Promise((resolve, reject) => { + const tx = db.transaction(IDB_STORE_NAME, 'readwrite'); + tx.objectStore(IDB_STORE_NAME).delete(key); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +} + +export type StorageWriteResult = { + sessionStorage: boolean; + localStorage: boolean; + indexedDB: boolean; + cookie: boolean; +}; + +/** + * Writes a value to sessionStorage, localStorage, IndexedDB, and a first-party cookie. + * Failures in any individual layer are logged and swallowed so a single + * unavailable store (e.g. private-browsing restrictions) does not block the flow. + * Returns a result map indicating which layers succeeded. + */ +export async function storageWrite(key: string, value: string): Promise { + const result: StorageWriteResult = { sessionStorage: false, localStorage: false, indexedDB: false, cookie: false }; + + try { + sessionStorage.setItem(key, value); + result.sessionStorage = true; + } catch (err) { + logger.error('oauth2.storage.write_failed', { + storage: { layer: 'sessionStorage', key, errorMsg: err instanceof Error ? err.message : String(err) }, + }); + } + + try { + localStorage.setItem(key, value); + result.localStorage = true; + } catch (err) { + logger.error('oauth2.storage.write_failed', { + storage: { layer: 'localStorage', key, errorMsg: err instanceof Error ? err.message : String(err) }, + }); + } + + try { + await idbWrite(key, value); + result.indexedDB = true; + } catch (err) { + logger.error('oauth2.storage.write_failed', { + storage: { layer: 'indexedDB', key, errorMsg: err instanceof Error ? err.message : String(err) }, + }); + } + + try { + cookieWrite(key, value); + result.cookie = true; + } catch (err) { + logger.error('oauth2.storage.write_failed', { + storage: { layer: 'cookie', key, errorMsg: err instanceof Error ? err.message : String(err) }, + }); + } + + return result; +} + +export type StorageSource = 'sessionStorage' | 'localStorage' | 'indexedDB' | 'cookie' | null; + +/** + * Reads a value from sessionStorage, localStorage, IndexedDB, then cookie — first non-null wins. + * The cookie layer is the most reliable on iOS (ITP-safe, survives ASWebAuthenticationSession). + * Returns the value along with which layer it came from. + */ +export async function storageRead(key: string): Promise<{ value: string | null; source: StorageSource }> { + try { + const fromSession = sessionStorage.getItem(key); + if (fromSession !== null) return { value: fromSession, source: 'sessionStorage' }; + } catch (err) { + logger.error('oauth2.storage.read_failed', { + storage: { layer: 'sessionStorage', key, errorMsg: err instanceof Error ? err.message : String(err) }, + }); + } + + try { + const fromLocal = localStorage.getItem(key); + if (fromLocal !== null) return { value: fromLocal, source: 'localStorage' }; + } catch (err) { + logger.error('oauth2.storage.read_failed', { + storage: { layer: 'localStorage', key, errorMsg: err instanceof Error ? err.message : String(err) }, + }); + } + + try { + const fromIdb = await idbRead(key); + if (fromIdb !== null) return { value: fromIdb, source: 'indexedDB' }; + } catch (err) { + logger.error('oauth2.storage.read_failed', { + storage: { layer: 'indexedDB', key, errorMsg: err instanceof Error ? err.message : String(err) }, + }); + } + + try { + const fromCookie = cookieRead(key); + if (fromCookie !== null) return { value: fromCookie, source: 'cookie' }; + } catch (err) { + logger.error('oauth2.storage.read_failed', { + storage: { layer: 'cookie', key, errorMsg: err instanceof Error ? err.message : String(err) }, + }); + } + + return { value: null, source: null }; +} + +/** + * Removes a key from sessionStorage, localStorage, IndexedDB, and the cookie. + */ +export async function storageRemove(key: string): Promise { + try { + sessionStorage.removeItem(key); + } catch (err) { + logger.error('oauth2.storage.remove_failed', { + storage: { layer: 'sessionStorage', key, errorMsg: err instanceof Error ? err.message : String(err) }, + }); + } + + try { + localStorage.removeItem(key); + } catch (err) { + logger.error('oauth2.storage.remove_failed', { + storage: { layer: 'localStorage', key, errorMsg: err instanceof Error ? err.message : String(err) }, + }); + } + + try { + await idbRemove(key); + } catch (err) { + logger.error('oauth2.storage.remove_failed', { + storage: { layer: 'indexedDB', key, errorMsg: err instanceof Error ? err.message : String(err) }, + }); + } + + try { + cookieRemove(key); + } catch (err) { + logger.error('oauth2.storage.remove_failed', { + storage: { layer: 'cookie', key, errorMsg: err instanceof Error ? err.message : String(err) }, + }); + } +} diff --git a/yarn.lock b/yarn.lock index a89ab255a..5fce30a9f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1604,6 +1604,27 @@ __metadata: languageName: node linkType: hard +"@datadog/browser-core@npm:5.35.1": + version: 5.35.1 + resolution: "@datadog/browser-core@npm:5.35.1" + checksum: f5706a839ed572e59694ac7a13f2d298079f67b9b9fa6ca5e397e1dc72b4c755a90c85d0ed0f4c8f292c38d7e9b8c1e94dbe80dd27fbbd0fe37ecf7aad61a1cb + languageName: node + linkType: hard + +"@datadog/browser-logs@npm:^5.0.0": + version: 5.35.1 + resolution: "@datadog/browser-logs@npm:5.35.1" + dependencies: + "@datadog/browser-core": 5.35.1 + peerDependencies: + "@datadog/browser-rum": 5.35.1 + peerDependenciesMeta: + "@datadog/browser-rum": + optional: true + checksum: 47156dd78f33aa1b6971a8131730bdcd87cd094cfc5ea5bae938107362c86241077a45668f748bb0e70a51d58c1aa4781bbeb4c3b595a04f3ae4a0367da28237 + languageName: node + linkType: hard + "@ecies/ciphers@npm:^0.2.4": version: 0.2.5 resolution: "@ecies/ciphers@npm:0.2.5" @@ -3442,6 +3463,7 @@ __metadata: version: 0.0.0-use.local resolution: "@magic-ext/oauth2@workspace:packages/@magic-ext/oauth2" dependencies: + "@datadog/browser-logs": ^5.0.0 "@magic-sdk/provider": ^33.5.0 "@types/crypto-js": 4.2.0 crypto-js: ^4.2.0