Skip to content

Commit 1decb3c

Browse files
committed
feat: implement secure storage
1 parent d7119a1 commit 1decb3c

File tree

10 files changed

+594
-64
lines changed

10 files changed

+594
-64
lines changed

packages/rn-sample/App.tsx

Lines changed: 47 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,64 @@
1+
// eslint-disable-next-line import/no-unassigned-import
12
import '@logto/rn/polyfill';
23

4+
import { LogtoProvider, useLogto, type IdTokenClaims } from '@logto/rn';
35
import { StatusBar } from 'expo-status-bar';
6+
import { useEffect, useState } from 'react';
47
import { Button, StyleSheet, Text, View } from 'react-native';
5-
import { LogtoProvider, useLogto } from '@logto/rn';
6-
import { useEffect } from 'react';
78

89
const redirectUri = 'io.logto://callback';
910

10-
function Content() {
11-
const { signIn, signOut, client, isAuthenticated, getRefreshToken } = useLogto();
11+
const Content = () => {
12+
const { signIn, signOut, client, isAuthenticated, getIdTokenClaims } = useLogto();
13+
const [claims, setClaims] = useState<IdTokenClaims>();
1214

1315
useEffect(() => {
1416
const get = async () => {
15-
const token = await getRefreshToken();
16-
console.log('refresh', token);
17+
setClaims(await getIdTokenClaims());
1718
};
1819

1920
if (isAuthenticated) {
20-
get();
21+
void get();
2122
}
22-
}, [isAuthenticated, getRefreshToken]);
23+
}, [isAuthenticated, getIdTokenClaims]);
2324

2425
return (
2526
<View style={styles.container}>
26-
<Text>{client.logtoConfig.appId}</Text>
27-
{
28-
isAuthenticated ? (
29-
<Button title="Sign out" onPress={() => signOut()} />
30-
) : (
31-
<Button title="Sign in" onPress={() => signIn(redirectUri)} />
32-
)
33-
}
27+
<Text style={styles.metadata}>App ID: {client.logtoConfig.appId}</Text>
28+
{isAuthenticated ? (
29+
<>
30+
<Text style={styles.title}>Authenticated</Text>
31+
{claims &&
32+
Object.entries(claims).map(([key, value]) => (
33+
<Text key={key}>
34+
{key}: {String(value)}
35+
</Text>
36+
))}
37+
<Button title="Sign out" onPress={async () => signOut()} />
38+
</>
39+
) : (
40+
<Button title="Sign in" onPress={async () => signIn(redirectUri)} />
41+
)}
42+
{/* eslint-disable-next-line react/style-prop-object */}
3443
<StatusBar style="auto" />
3544
</View>
3645
);
37-
}
38-
39-
export default function App() {
40-
46+
};
4147

48+
const App = () => {
4249
return (
43-
<LogtoProvider config={{
44-
endpoint: 'http://localhost:3002/',
45-
appId: 's5sc0ktp6fs0a8rwqod6h',
46-
}}>
50+
<LogtoProvider
51+
config={{
52+
endpoint: 'http://localhost:3002/',
53+
appId: 's5sc0ktp6fs0a8rwqod6h',
54+
}}
55+
>
4756
<Content />
48-
</LogtoProvider>
57+
</LogtoProvider>
4958
);
50-
}
59+
};
60+
61+
export default App;
5162

5263
const styles = StyleSheet.create({
5364
container: {
@@ -56,4 +67,14 @@ const styles = StyleSheet.create({
5667
alignItems: 'center',
5768
justifyContent: 'center',
5869
},
70+
title: {
71+
fontSize: 20,
72+
marginBottom: 16,
73+
},
74+
metadata: {
75+
fontSize: 14,
76+
marginBottom: 16,
77+
fontStyle: 'italic',
78+
color: '#666',
79+
},
5980
});

packages/rn-sample/package.json

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"name": "@logto/rn-sample",
3+
"private": true,
34
"version": "1.0.0",
45
"main": "index.js",
56
"scripts": {
@@ -11,16 +12,22 @@
1112
"dependencies": {
1213
"@logto/rn": "workspace:^",
1314
"expo": "~50.0.6",
14-
"expo-crypto": "^12.8.0",
1515
"expo-status-bar": "~1.11.1",
16-
"expo-web-browser": "^12.8.2",
1716
"react": "18.2.0",
1817
"react-native": "0.73.4"
1918
},
2019
"devDependencies": {
2120
"@babel/core": "^7.20.0",
21+
"@silverhand/eslint-config": "^5.0.0",
22+
"@silverhand/eslint-config-react": "^5.0.0",
2223
"@types/react": "~18.2.45",
24+
"eslint": "^8.56.0",
25+
"prettier": "^3.2.5",
26+
"stylelint": "^16.2.1",
2327
"typescript": "^5.1.3"
2428
},
25-
"private": true
29+
"eslintConfig": {
30+
"extends": "@silverhand/react"
31+
},
32+
"prettier": "@silverhand/eslint-config/.prettierrc"
2633
}

packages/rn-sample/tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"extends": "expo/tsconfig.base",
33
"compilerOptions": {
4+
"moduleResolution": "bundler",
45
"strict": true
56
}
67
}

packages/rn/package.json

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,6 @@
3737
"@silverhand/ts-config-react": "^5.0.0",
3838
"@types/react": "~18.2.45",
3939
"eslint": "^8.56.0",
40-
"expo-crypto": "^12.8.0",
41-
"expo-web-browser": "^12.8.2",
4240
"prettier": "^3.2.5",
4341
"stylelint": "^16.2.1",
4442
"typescript": "^5.3.3"
@@ -50,13 +48,14 @@
5048
"publishConfig": {
5149
"access": "public"
5250
},
53-
"peerDependencies": {
54-
"expo-crypto": "^12.8.0",
55-
"expo-web-browser": "^12.8.2"
56-
},
5751
"dependencies": {
5852
"@logto/client": "3.0.0-alpha.2",
5953
"@logto/js": "4.0.0-alpha.2",
54+
"@react-native-async-storage/async-storage": "^1.22.0",
55+
"crypto-es": "^2.1.0",
56+
"expo-crypto": "^12.8.0",
57+
"expo-secure-store": "^12.8.1",
58+
"expo-web-browser": "^12.8.2",
6059
"js-base64": "^3.7.6"
6160
}
6261
}

packages/rn/src/client.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@ import {
55
type InteractionMode,
66
Prompt,
77
LogtoError,
8-
} from '@logto/client/lib/shim';
8+
} from '@logto/client/shim';
99
import { decodeIdToken } from '@logto/js';
1010
import * as WebBrowser from 'expo-web-browser';
1111

1212
import { LogtoNativeClientError } from './errors';
13-
import { MemoryStorage } from './storage';
13+
import { SecureStorage } from './storage';
1414
import { generateCodeChallenge, generateRandomString } from './utils';
1515

1616
const issuedAtTimeTolerance = 300; // 5 minutes
@@ -32,17 +32,17 @@ export type LogtoNativeConfig = LogtoConfig & {
3232
* between the authentication session and the user’s normal browser session. Whether the request
3333
* is honored depends on the user’s default web browser.
3434
*
35-
* @default false
35+
* @default true
3636
*/
3737
preferEphemeralSession?: boolean;
3838
};
3939

4040
export class LogtoClient extends StandardLogtoClient {
41-
storage: MemoryStorage;
4241
authSessionResult?: WebBrowser.WebBrowserAuthSessionResult;
42+
protected storage: SecureStorage;
4343

4444
constructor(config: LogtoNativeConfig) {
45-
const storage = new MemoryStorage();
45+
const storage = new SecureStorage(`logto.${config.appId}`);
4646
const requester = createRequester(fetch);
4747

4848
super(
@@ -54,7 +54,7 @@ export class LogtoClient extends StandardLogtoClient {
5454
case 'sign-in': {
5555
this.authSessionResult = undefined;
5656
this.authSessionResult = await WebBrowser.openAuthSessionAsync(url, redirectUri, {
57-
preferEphemeralSession: config.preferEphemeralSession,
57+
preferEphemeralSession: config.preferEphemeralSession ?? true,
5858
});
5959
break;
6060
}

packages/rn/src/index.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,29 @@
1+
export type {
2+
IdTokenClaims,
3+
LogtoErrorCode,
4+
LogtoConfig,
5+
LogtoClientErrorCode,
6+
UserInfoResponse,
7+
InteractionMode,
8+
ClientAdapter,
9+
} from '@logto/client/shim';
10+
11+
export {
12+
createRequester,
13+
LogtoError,
14+
LogtoRequestError,
15+
LogtoClientError,
16+
OidcError,
17+
Prompt,
18+
ReservedScope,
19+
ReservedResource,
20+
UserScope,
21+
organizationUrnPrefix,
22+
buildOrganizationUrn,
23+
getOrganizationIdFromUrn,
24+
PersistKey,
25+
} from '@logto/client/shim';
26+
127
export * from './client';
228
export * from './context';
329
export * from './errors';

packages/rn/src/storage.ts

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,57 @@
1-
import { type Storage } from '@logto/client/lib/shim';
1+
import { type Storage } from '@logto/client/shim';
2+
import AsyncStorage from '@react-native-async-storage/async-storage';
3+
import CryptoES from 'crypto-es';
4+
import * as SecureStore from 'expo-secure-store';
25

3-
export class MemoryStorage implements Storage<string> {
4-
storage = new Map<string, string>();
6+
import { generateRandomString } from './utils';
7+
8+
export class SecureStorage implements Storage<string> {
9+
constructor(public id: string) {}
10+
11+
getStorageKey(key: string) {
12+
return `${this.id}.${key}`;
13+
}
514

615
async getItem(key: string) {
7-
return this.storage.get(key) ?? null;
16+
const storageKey = this.getStorageKey(key);
17+
const encrypted = await AsyncStorage.getItem(storageKey);
18+
19+
if (!encrypted) {
20+
return null;
21+
}
22+
23+
return this.#decrypt(storageKey, encrypted);
824
}
925

1026
async setItem(key: string, value: string) {
11-
this.storage.set(key, value);
27+
const storageKey = this.getStorageKey(key);
28+
const encrypted = await this.#encrypt(storageKey, value);
29+
await AsyncStorage.setItem(storageKey, encrypted);
1230
}
1331

1432
async removeItem(key: string) {
15-
this.storage.delete(key);
33+
const storageKey = this.getStorageKey(key);
34+
await Promise.all([
35+
AsyncStorage.removeItem(storageKey),
36+
SecureStore.deleteItemAsync(storageKey),
37+
]);
38+
}
39+
40+
async #encrypt(key: string, value: string) {
41+
const encryptionKey = await generateRandomString();
42+
const encrypted = CryptoES.AES.encrypt(value, encryptionKey).toString();
43+
44+
await SecureStore.setItemAsync(key, encryptionKey);
45+
return encrypted;
46+
}
47+
48+
async #decrypt(key: string, value: string) {
49+
const encryptionKey = await SecureStore.getItemAsync(key);
50+
51+
if (!encryptionKey) {
52+
return null;
53+
}
54+
55+
return CryptoES.AES.decrypt(value, encryptionKey).toString(CryptoES.enc.Utf8);
1656
}
1757
}

packages/rn/src/utils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import * as Crypto from 'expo-crypto';
22
import { encode, fromUint8Array, toUint8Array } from 'js-base64';
33

4-
export const generateRandomString = async (length = 64) =>
5-
fromUint8Array(await Crypto.getRandomBytesAsync(length), true);
4+
export const generateRandomString = async (byteLength = 64) =>
5+
fromUint8Array(await Crypto.getRandomBytesAsync(byteLength), true);
66

77
export const generateCodeChallenge = async (codeVerifier: string): Promise<string> => {
88
const codeChallenge = new Uint8Array(

packages/rn/tsconfig.json

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,7 @@
22
"extends": "@silverhand/ts-config-react/tsconfig.base",
33
"compilerOptions": {
44
"noEmit": false,
5-
"outDir": "lib",
6-
"moduleResolution": "node10",
7-
"module": "commonjs"
5+
"outDir": "lib"
86
},
97
"include": [
108
"src",

0 commit comments

Comments
 (0)