Skip to content

Commit d7119a1

Browse files
committed
feat: init rn sdk
1 parent c244c6e commit d7119a1

21 files changed

+3224
-601
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ node_modules/
77
.expo/
88
dist/
99
web-build/
10+
lib/
1011

1112
# Native
1213
*.orig.*

.vscode/extensions.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"recommendations": [
3+
"dbaeumer.vscode-eslint",
4+
"stylelint.vscode-stylelint"
5+
]
6+
}

.vscode/settings.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"typescript.tsdk": "node_modules/typescript/lib",
3+
"[scss]": {
4+
"editor.codeActionsOnSave": {
5+
"source.fixAll.stylelint": "explicit"
6+
}
7+
},
8+
"eslint.workingDirectories": [
9+
{
10+
"pattern": "./packages/*"
11+
}
12+
],
13+
"eslint.validate": [
14+
"javascript",
15+
"javascriptreact",
16+
"typescript",
17+
"typescriptreact",
18+
],
19+
"editor.codeActionsOnSave": {
20+
"source.fixAll.eslint": "explicit"
21+
},
22+
"cSpell.words": [
23+
"logto",
24+
"oidc"
25+
]
26+
}

packages/rn-sample/App.tsx

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,54 @@
1+
import '@logto/rn/polyfill';
2+
13
import { StatusBar } from 'expo-status-bar';
2-
import { StyleSheet, Text, View } from 'react-native';
4+
import { Button, StyleSheet, Text, View } from 'react-native';
5+
import { LogtoProvider, useLogto } from '@logto/rn';
6+
import { useEffect } from 'react';
7+
8+
const redirectUri = 'io.logto://callback';
9+
10+
function Content() {
11+
const { signIn, signOut, client, isAuthenticated, getRefreshToken } = useLogto();
12+
13+
useEffect(() => {
14+
const get = async () => {
15+
const token = await getRefreshToken();
16+
console.log('refresh', token);
17+
};
18+
19+
if (isAuthenticated) {
20+
get();
21+
}
22+
}, [isAuthenticated, getRefreshToken]);
323

4-
export default function App() {
524
return (
625
<View style={styles.container}>
7-
<Text>Open up App.tsx to start working on your app!</Text>
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+
}
834
<StatusBar style="auto" />
935
</View>
1036
);
1137
}
1238

39+
export default function App() {
40+
41+
42+
return (
43+
<LogtoProvider config={{
44+
endpoint: 'http://localhost:3002/',
45+
appId: 's5sc0ktp6fs0a8rwqod6h',
46+
}}>
47+
<Content />
48+
</LogtoProvider>
49+
);
50+
}
51+
1352
const styles = StyleSheet.create({
1453
container: {
1554
flex: 1,

packages/rn-sample/index.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
/**
2+
* @fileoverview https://docs.expo.dev/guides/monorepos/#change-default-entrypoint
3+
*/
4+
15
import { registerRootComponent } from 'expo';
26

37
import App from './App';

packages/rn-sample/metro.config.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* @fileoverview https://docs.expo.dev/guides/monorepos/#modify-the-metro-config
3+
*/
4+
5+
const { getDefaultConfig } = require('expo/metro-config');
6+
const path = require('path');
7+
8+
// Find the project and workspace directories
9+
const projectRoot = __dirname;
10+
// This can be replaced with `find-yarn-workspace-root`
11+
const monorepoRoot = path.resolve(projectRoot, '../..');
12+
13+
const config = getDefaultConfig(projectRoot);
14+
15+
// 1. Watch all files within the monorepo
16+
config.watchFolders = [monorepoRoot];
17+
// 2. Let Metro know where to resolve packages and in what order
18+
config.resolver.nodeModulesPaths = [
19+
path.resolve(projectRoot, 'node_modules'),
20+
path.resolve(monorepoRoot, 'node_modules'),
21+
];
22+
23+
config.resolver.unstable_enablePackageExports = true;
24+
25+
module.exports = config;

packages/rn-sample/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,11 @@
99
"web": "expo start --web"
1010
},
1111
"dependencies": {
12+
"@logto/rn": "workspace:^",
1213
"expo": "~50.0.6",
14+
"expo-crypto": "^12.8.0",
1315
"expo-status-bar": "~1.11.1",
16+
"expo-web-browser": "^12.8.2",
1417
"react": "18.2.0",
1518
"react-native": "0.73.4"
1619
},

packages/rn-sample/tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"extends": "../../node_modules/expo/tsconfig.base",
2+
"extends": "expo/tsconfig.base",
33
"compilerOptions": {
44
"strict": true
55
}

packages/rn/package.json

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
{
2+
"name": "@logto/rn",
3+
"version": "0.1.0",
4+
"main": "./lib/index.js",
5+
"types": "./lib/index.d.ts",
6+
"exports": {
7+
".": {
8+
"import": "./lib/index.js",
9+
"require": "./lib/index.js",
10+
"types": "./lib/index.d.ts"
11+
},
12+
"./polyfill": {
13+
"import": "./lib/polyfill.js",
14+
"require": "./lib/polyfill.js"
15+
}
16+
},
17+
"files": [
18+
"lib"
19+
],
20+
"license": "MIT",
21+
"repository": {
22+
"type": "git",
23+
"url": "https://github.com/logto-io/react-native.git",
24+
"directory": "packages/rn"
25+
},
26+
"scripts": {
27+
"dev:tsc": "tsc -p tsconfig.build.json -w --preserveWatchOutput",
28+
"check": "tsc --noEmit",
29+
"build": "rm -rf lib/ && tsc -p tsconfig.build.json",
30+
"lint": "eslint --ext .ts src"
31+
},
32+
"devDependencies": {
33+
"@babel/preset-env": "^7.23.9",
34+
"@silverhand/eslint-config": "^5.0.0",
35+
"@silverhand/eslint-config-react": "^5.0.0",
36+
"@silverhand/ts-config": "^5.0.0",
37+
"@silverhand/ts-config-react": "^5.0.0",
38+
"@types/react": "~18.2.45",
39+
"eslint": "^8.56.0",
40+
"expo-crypto": "^12.8.0",
41+
"expo-web-browser": "^12.8.2",
42+
"prettier": "^3.2.5",
43+
"stylelint": "^16.2.1",
44+
"typescript": "^5.3.3"
45+
},
46+
"eslintConfig": {
47+
"extends": "@silverhand/react"
48+
},
49+
"prettier": "@silverhand/eslint-config/.prettierrc",
50+
"publishConfig": {
51+
"access": "public"
52+
},
53+
"peerDependencies": {
54+
"expo-crypto": "^12.8.0",
55+
"expo-web-browser": "^12.8.2"
56+
},
57+
"dependencies": {
58+
"@logto/client": "3.0.0-alpha.2",
59+
"@logto/js": "4.0.0-alpha.2",
60+
"js-base64": "^3.7.6"
61+
}
62+
}

packages/rn/src/client.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import {
2+
type LogtoConfig,
3+
createRequester,
4+
StandardLogtoClient,
5+
type InteractionMode,
6+
Prompt,
7+
LogtoError,
8+
} from '@logto/client/lib/shim';
9+
import { decodeIdToken } from '@logto/js';
10+
import * as WebBrowser from 'expo-web-browser';
11+
12+
import { LogtoNativeClientError } from './errors';
13+
import { MemoryStorage } from './storage';
14+
import { generateCodeChallenge, generateRandomString } from './utils';
15+
16+
const issuedAtTimeTolerance = 300; // 5 minutes
17+
18+
export type LogtoNativeConfig = LogtoConfig & {
19+
/**
20+
* The prompt to be used for the authentication request. This can be used to skip the login or
21+
* consent screen when the user has already granted the required permissions.
22+
*
23+
* @see {@link https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest | OpenID Connect Core 1.0}
24+
* @default [Prompt.Login, Prompt.Consent]
25+
*/
26+
prompt?: Prompt | Prompt[];
27+
/**
28+
* **Only for iOS**
29+
*
30+
* Determines whether the session should ask the browser for a private authentication session.
31+
* Set this to true to request that the browser doesn’t share cookies or other browsing data
32+
* between the authentication session and the user’s normal browser session. Whether the request
33+
* is honored depends on the user’s default web browser.
34+
*
35+
* @default false
36+
*/
37+
preferEphemeralSession?: boolean;
38+
};
39+
40+
export class LogtoClient extends StandardLogtoClient {
41+
storage: MemoryStorage;
42+
authSessionResult?: WebBrowser.WebBrowserAuthSessionResult;
43+
44+
constructor(config: LogtoNativeConfig) {
45+
const storage = new MemoryStorage();
46+
const requester = createRequester(fetch);
47+
48+
super(
49+
{ prompt: [Prompt.Consent], ...config },
50+
{
51+
requester,
52+
navigate: async (url: string, { redirectUri, for: purpose }) => {
53+
switch (purpose) {
54+
case 'sign-in': {
55+
this.authSessionResult = undefined;
56+
this.authSessionResult = await WebBrowser.openAuthSessionAsync(url, redirectUri, {
57+
preferEphemeralSession: config.preferEphemeralSession,
58+
});
59+
break;
60+
}
61+
case 'sign-out': {
62+
break;
63+
}
64+
default: {
65+
throw new LogtoNativeClientError('auth_session_failed');
66+
}
67+
}
68+
},
69+
storage,
70+
generateCodeChallenge,
71+
generateCodeVerifier: generateRandomString,
72+
generateState: generateRandomString,
73+
},
74+
(client) => ({
75+
// Due to the limitation of Expo, we could not verify JWT signature on the client side.
76+
// Thus we only decode the token and verify the claims here. The signature verification
77+
// may be done on the server side or in the future when the limitation is resolved.
78+
//
79+
// Limitations:
80+
// - Lack of support for the crypto module or Web Crypto API.
81+
// - Lack of support for native modules in the managed workflow.
82+
verifyIdToken: async (idToken: string) => {
83+
const { issuer } = await client.getOidcConfig();
84+
const claims = decodeIdToken(idToken);
85+
86+
if (Math.abs(claims.iat - Date.now() / 1000) > issuedAtTimeTolerance) {
87+
throw new LogtoError('id_token.invalid_iat');
88+
}
89+
90+
if (claims.aud !== client.logtoConfig.appId || claims.iss !== issuer) {
91+
throw new LogtoError('id_token.invalid_token');
92+
}
93+
},
94+
})
95+
);
96+
97+
this.storage = storage;
98+
}
99+
100+
override async signIn(redirectUri: string, interactionMode?: InteractionMode): Promise<void> {
101+
await super.signIn(redirectUri, interactionMode);
102+
103+
if (this.authSessionResult?.type !== 'success') {
104+
throw new LogtoNativeClientError('auth_session_failed');
105+
}
106+
107+
await this.handleSignInCallback(this.authSessionResult.url);
108+
}
109+
}

0 commit comments

Comments
 (0)