Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/@magic-ext/oauth2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
87 changes: 61 additions & 26 deletions packages/@magic-ext/oauth2/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<typeof this.utils.createJsonRpcRequestPayload>;

const promiEvent = this.utils.createPromiEvent<OAuthRedirectResult, OAuthGetResultEventHandlers>(
async (resolve, reject) => {
const { hasStateMismatch, clientMetadata } = await this.retrievePKCEMetadata(queryString);

if (!clientMetadata) {
return reject(
this.createError<object>(
Expand All @@ -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<OAuthRedirectResult | OAuthRedirectError, OAuthGetResultEventHandlers>(
requestPayload,
);
Expand Down Expand Up @@ -318,25 +335,36 @@ export class OAuthExtension extends Extension.Internal<'oauth2'> {
}
}

private retrievePKCEMetadata(queryString: string): {
private async retrievePKCEMetadata(queryString: string): Promise<{
clientMetadata?: Record<string, string>;
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<string, string>)
: storedInLocal
? (JSON.parse(storedInLocal) as Record<string, string>)
: undefined;
const clientMetadata = stored ? (JSON.parse(stored) as Record<string, string>) : 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.
Expand All @@ -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,
},
});
}
}

Expand Down
30 changes: 30 additions & 0 deletions packages/@magic-ext/oauth2/src/logger.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

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),
};
Loading