Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
10eaea5
feat: implement keychain for rt
sherzod-bakhodirov Nov 21, 2025
e67fa2d
feat: move createMagicRequest to the methods of View Controller
sherzod-bakhodirov Nov 27, 2025
427d086
feat: implement dpop generation using react-native-biometrics
sherzod-bakhodirov Dec 1, 2025
a8b9d6c
feat: implement dpop creation using device crypto
sherzod-bakhodirov Dec 1, 2025
2ec46d8
chore: update yarn.lock
sherzod-bakhodirov Dec 1, 2025
10d15f5
feat: implement unique key alias
sherzod-bakhodirov Dec 3, 2025
51717f0
feat: use key alias for rt and jwt
sherzod-bakhodirov Dec 3, 2025
f2ac877
chore: remove unused var
sherzod-bakhodirov Dec 3, 2025
260ebf7
feat: implement caching for refresh token
sherzod-bakhodirov Dec 3, 2025
36b994c
chore: update comments
sherzod-bakhodirov Dec 4, 2025
7a6369e
Add podspec
Ethella Dec 4, 2025
cdb8909
Merge branch 'master' into implement-keychain-for-rt
Ethella Dec 5, 2025
dfe5a75
fix awaits
Ethella Dec 5, 2025
9d9adc5
Merge branch 'implement-keychain-for-rt' of github.com:magiclabs/magi…
Ethella Dec 5, 2025
309af02
feat: add tests for rt & dpop
sherzod-bakhodirov Dec 5, 2025
c4c1ed1
feat: show warning if peer dependencies are not installed
sherzod-bakhodirov Dec 5, 2025
c6b273e
chore: update yarn.lock
sherzod-bakhodirov Dec 5, 2025
5751805
chore: update yarn.lock
sherzod-bakhodirov Dec 5, 2025
ceb9c24
feat: implement tests for native module checks
sherzod-bakhodirov Dec 5, 2025
2162a5f
Merge branch 'master' into implement-keychain-for-rt
sherzod-bakhodirov Dec 8, 2025
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
79 changes: 71 additions & 8 deletions packages/@magic-sdk/provider/src/core/view-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ import {
import { JsonRpcResponse } from './json-rpc';
import { createPromise } from '../util/promise-tools';
import { MagicSDKWarning, createModalNotReadyError } from './sdk-exceptions';
import { clearDeviceShares, encryptAndPersistDeviceShare } from '../util/device-share-web-crypto';
import {
createMagicRequest,
persistMagicEventRefreshToken,
standardizeResponse,
debounce,
} from '../util/view-controller-utils';
clearDeviceShares,
encryptAndPersistDeviceShare,
getDecryptedDeviceShare,
} from '../util/device-share-web-crypto';
import { standardizeResponse, debounce, StandardizedMagicRequest } from '../util/view-controller-utils';
import { setItem, getItem } from '../util/storage';
import { SDKEnvironment } from './sdk-environment';
import { createJwt } from '../util/web-crypto';

interface RemoveEventListenerFunction {
(): void;
Expand Down Expand Up @@ -102,7 +104,7 @@ export abstract class ViewController {

const batchData: JsonRpcResponse[] = [];
const batchIds = Array.isArray(payload) ? payload.map(p => p.id) : [];
const msg = await createMagicRequest(`${msgType}-${this.parameters}`, payload, this.networkHash);
const msg = await this.createMagicRequest(`${msgType}-${this.parameters}`, payload, this.networkHash);

await this._post(msg);

Expand All @@ -111,7 +113,7 @@ export abstract class ViewController {
*/
const acknowledgeResponse = (removeEventListener: RemoveEventListenerFunction) => (event: MagicMessageEvent) => {
const { id, response } = standardizeResponse(payload, event);
persistMagicEventRefreshToken(event);
this.persistMagicEventRefreshToken(event);
if (response?.payload.error?.message === 'User denied account access.') {
clearDeviceShares();
} else if (event.data.deviceShare) {
Expand Down Expand Up @@ -249,4 +251,65 @@ export abstract class ViewController {
this.heartbeatIntervalTimer = null;
}
}

async persistMagicEventRefreshToken(event: MagicMessageEvent) {
if (!event.data.rt) {
return;
}

await setItem('rt', event.data.rt);
}

async createMagicRequest(
msgType: string,
payload: JsonRpcRequestPayload | JsonRpcRequestPayload[],
networkHash: string,
) {
const request: StandardizedMagicRequest = { msgType, payload };

const rt = await this.getRT();
const jwt = await this.getJWT();
const decryptedDeviceShare = await this.getDecryptedDeviceShare(networkHash);

if (jwt) {
request.jwt = jwt;
}

if (jwt && rt) {
request.rt = rt;
}

// Grab the device share if it exists for the network
if (decryptedDeviceShare) {
request.deviceShare = decryptedDeviceShare;
}

return request;
}

async getJWT(): Promise<string | null | undefined> {
// only for webcrypto platforms
if (SDKEnvironment.platform === 'web') {
try {
const jwtFromStorage = await getItem<string>('jwt');
if (jwtFromStorage) return jwtFromStorage;

const newJwt = await createJwt();
return newJwt;
} catch (e) {
console.error('webcrypto error', e);
return null;
}
} else {
return null;
}
}

async getRT(): Promise<string | null> {
return await getItem<string>('rt');
}

async getDecryptedDeviceShare(networkHash: string) {
return await getDecryptedDeviceShare(networkHash);
}
}
45 changes: 1 addition & 44 deletions packages/@magic-sdk/provider/src/util/view-controller-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ interface StandardizedResponse {
response?: JsonRpcResponse;
}

interface StandardizedMagicRequest {
export interface StandardizedMagicRequest {
msgType: string;
payload: JsonRpcRequestPayload | JsonRpcRequestPayload[];
jwt?: string;
Expand Down Expand Up @@ -53,49 +53,6 @@ export function standardizeResponse(
return {};
}

export async function createMagicRequest(
msgType: string,
payload: JsonRpcRequestPayload | JsonRpcRequestPayload[],
networkHash: string,
) {
const rt = await getItem<string>('rt');
let jwt;

// only for webcrypto platforms
if (SDKEnvironment.platform === 'web') {
try {
jwt = (await getItem<string>('jwt')) ?? (await createJwt());
} catch (e) {
console.error('webcrypto error', e);
}
}

const request: StandardizedMagicRequest = { msgType, payload };

if (jwt) {
request.jwt = jwt;
}
if (jwt && rt) {
request.rt = rt;
}

// Grab the device share if it exists for the network
const decryptedDeviceShare = await getDecryptedDeviceShare(networkHash);
if (decryptedDeviceShare) {
request.deviceShare = decryptedDeviceShare;
}

return request;
}

export async function persistMagicEventRefreshToken(event: MagicMessageEvent) {
if (!event.data.rt) {
return;
}

await setItem('rt', event.data.rt);
}

export function debounce<T extends (...args: unknown[]) => void>(func: T, delay: number) {
let timeoutId: ReturnType<typeof setTimeout> | null = null;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
require "json"

package = JSON.parse(File.read(File.join(__dir__, "package.json")))

Pod::Spec.new do |s|
s.name = "MagicSdkReactNativeBare"
s.version = package["version"]
s.summary = package["description"]
s.description = <<-DESC
#{package["description"]}
DESC
s.homepage = package["homepage"]
s.license = package["license"]
s.author = package["author"]
s.platforms = { :ios => "13.4" }
s.source = { :git => package["repository"]["url"].gsub(/.git$/, ''), :tag => "#{s.version}" }

# This is a pure JavaScript package
# Native dependencies (react-native-keychain, react-native-device-crypto) will be
# automatically linked via React Native autolinking when this package is installed

s.dependency "React-Core"
end
8 changes: 7 additions & 1 deletion packages/@magic-sdk/react-native-bare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
},
"homepage": "https://magic.link",
"files": [
"dist"
"dist",
"*.podspec"
],
"target": "node",
"main": "./dist/cjs/index.js",
Expand All @@ -28,6 +29,7 @@
"process": "~0.11.10",
"react-native-device-info": "^10.3.0",
"react-native-event-listeners": "^1.0.7",
"react-native-uuid": "^2.0.3",
"regenerator-runtime": "0.13.9",
"tslib": "^2.0.3",
"whatwg-url": "~8.1.0"
Expand All @@ -38,7 +40,9 @@
"@testing-library/react-native": "^13.2.0",
"react": "~19.1.0",
"react-native": "~0.78.1",
"react-native-device-crypto": "^0.1.7",
"react-native-device-info": "^10.3.0",
"react-native-keychain": "^10.0.0",
"react-native-safe-area-context": "5.3.0",
"react-native-webview": "^13.3.0",
"react-test-renderer": "^19.1.0"
Expand All @@ -48,7 +52,9 @@
"@react-native-community/netinfo": ">=9.0.0",
"react": ">=16",
"react-native": ">=0.60",
"react-native-device-crypto": "^0.1.7",
"react-native-device-info": ">=10.3.0",
"react-native-keychain": "^10.0.0",
"react-native-safe-area-context": ">=4.4.1",
"react-native-webview": ">=12.4.0"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { NativeModules, Platform } from 'react-native';

interface NativeModuleCheck {
name: string;
nativeModuleName: string;
packageName: string;
}

const REQUIRED_NATIVE_MODULES: NativeModuleCheck[] = [
{
name: 'react-native-keychain',
nativeModuleName: 'RNKeychainManager',
packageName: 'react-native-keychain',
},
{
name: 'react-native-device-crypto',
nativeModuleName: 'DeviceCrypto',
packageName: 'react-native-device-crypto',
},
];

// Track if warning has been shown to avoid spamming
let hasWarned = false;

/**
* Checks if all required native modules are properly installed and linked.
* Logs a warning if any native module is missing.
*
* Note: Some native modules (like react-native-device-crypto) require hardware
* features (e.g., Secure Enclave) that are not available in simulators/emulators.
* The SDK will continue to work but certain security features may be degraded.
*/
export const checkNativeModules = (): void => {
if (hasWarned) return;

const missingModules: NativeModuleCheck[] = [];

for (const module of REQUIRED_NATIVE_MODULES) {
if (!NativeModules[module.nativeModuleName]) {
missingModules.push(module);
}
}

if (missingModules.length > 0) {
hasWarned = true;

const platform = Platform.OS;
const moduleList = missingModules.map(m => ` - ${m.packageName}`).join('\n');
const installCommands = missingModules.map(m => `npm install ${m.packageName}`).join('\n');

const iosInstructions =
platform === 'ios'
? `
For iOS, run:
cd ios && pod install && cd ..
`
: '';

const androidInstructions =
platform === 'android'
? `
For Android, rebuild your app:
npx react-native run-android
`
: '';

console.warn(
`@magic-sdk/react-native-bare: Missing native modules detected.

The following native modules are not linked:
${moduleList}

The SDK will continue to work, but some security features may not function properly.

Note: If you're running in a simulator/emulator, some native modules (like react-native-device-crypto)
require hardware features (Secure Enclave) that are only available on physical devices.

If you're on a physical device and see this warning, please ensure the packages are installed and linked:

1. Install the missing packages:
${installCommands}

2. Link the native modules:
${iosInstructions}${androidInstructions}
3. Rebuild your app completely.
`,
);
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const ALG = 'ES256';
export const TYP = 'dpop+jwt';
78 changes: 78 additions & 0 deletions packages/@magic-sdk/react-native-bare/src/native-crypto/dpop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import uuid from 'react-native-uuid';
import { toBase64Url } from './utils/uint8';
import { spkiToJwk } from './utils/jwk';
import { ALG, TYP } from './constants';
import { derToRawSignature } from './utils/der';
import { DpopClaims, DpopHeader } from './types';
import DeviceCrypto, { AccessLevel } from 'react-native-device-crypto';
import { getKeyAlias } from './utils/key-alias';

const KEY_ALIAS = getKeyAlias('dpop');

/**
* Generates the DPoP proof compatible with the Python backend.
* Handles key creation (if missing), JWK construction, and signing.
*/
export const getDpop = async (): Promise<string | null> => {
try {
// 1. Get or Create Key in Secure Enclave
// We strictly disable authentication to avoid biometric prompts
const publicKey = await DeviceCrypto.getOrCreateAsymmetricKey(KEY_ALIAS, {
accessLevel: AccessLevel.ALWAYS, // Key is always accessible in this device
invalidateOnNewBiometry: false,
});

// 2. Prepare Public Key as JWK
// Toaster backend expects JWK in the header
const publicJwk = spkiToJwk(publicKey);

// 3. Construct Payload
const now = Math.floor(Date.now() / 1000);
const claims: DpopClaims = {
iat: now,
jti: uuid.v4(),
};

const header: DpopHeader = {
typ: TYP,
alg: ALG,
jwk: publicJwk,
};

// 4. Prepare Signing Input
const headerB64 = toBase64Url(JSON.stringify(header));
const payloadB64 = toBase64Url(JSON.stringify(claims));
const signingInput = `${headerB64}.${payloadB64}`;

// 5. Sign Data
// DeviceCrypto returns a Base64 signature.
const signatureBase64 = await DeviceCrypto.sign(KEY_ALIAS, signingInput, {
// Biometry prompts should not be fired since the key is always accessible in this device
biometryTitle: 'Sign DPoP',
biometrySubTitle: 'Sign DPoP',
biometryDescription: 'Sign DPoP',
});

// 6. Convert Signature (Toaster expects Raw R|S)
const signatureB64 = derToRawSignature(signatureBase64);

return `${signingInput}.${signatureB64}`;
} catch (error) {
console.error('DPoP Generation Error:', error);
return null;
}
};

/**
* Removes the keys from the Secure Enclave
* Returns true if the key was deleted successfully, false otherwise.
* @returns {Promise<boolean>} True if the key was deleted successfully, false otherwise.
*/
export const deleteDpop = async (): Promise<boolean> => {
try {
return await DeviceCrypto.deleteKey(KEY_ALIAS);
} catch (error) {
console.error('DPoP Deletion Error:', error);
return false;
}
};
Loading
Loading