Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add the expo web platform support #22

Merged
merged 6 commits into from
Apr 19, 2024
Merged
Show file tree
Hide file tree
Changes from 4 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
36 changes: 30 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,26 +72,50 @@ const App = () => {

## Run the sample app

> [!Note]
> In terms of the redirect URI scheme, different platforms have different requirements.
>
> - For native platforms, a Private-Use native URI scheme is required. See [OAuth2 spec](https://datatracker.ietf.org/doc/html/rfc8252#section-8.4) for more details.
> - For web platforms (SPA), an `http(s)://` scheme is required.
>
> You may need to register different applications in the Logto dashboard for different platforms. Make sure to configure the correct `redirectUri` and `clientId` for different platforms.

### Replace the `appId` and `endpoint` in `App.tsx` with your own Logto settings.

```tsx
const endpoint = "YOUR_LOGTO_ENDPOINT";
const appId = "YOUR_APP_ID";
```

### Run using Expo Go
### Development using Expo Go

> [!Caution]
> This SDK is not compatible with "Expo Go" sandbox on Android.
> Under the hood, this SDK uses `ExpoAuthSession` to handle the user authentication flow. Native deep linking is not supported in "Expo Go". For more details please refer to [deep-linking](https://docs.expo.dev/guides/deep-linking/)
> Use [development-build](https://docs.expo.dev/develop/development-builds/introduction/) to test this SDK on Android.
#### For iOS

Under the path `packages/rn-sample` run the following command.
Customize the redirect URI e.g. `io.logto://callback` and pass it to the `signIn` function.

Run the following command under the path `packages/rn-sample`.

```bash
pnpm dev:ios
```

#### For web

Customize the redirect URI e.g. `http://localhost:19006` and pass it to the `signIn` function.

Run the following command under the path `packages/rn-sample`.

```bash
pnpm dev:web
```

#### For Android

> [!Caution]
> This SDK is not compatible with "Expo Go" sandbox on Android.
> Expo Go app by default uses `exp://` scheme for deep linking, which is not a valid private native scheme. See [OAuth2 spec](https://datatracker.ietf.org/doc/html/rfc8252#section-8.4) for more details.
> For Android, Use [development-build](https://docs.expo.dev/develop/development-builds/introduction/) to test this SDK

### Build and run native package

Under the path `packages/rn-sample` run the following command.
Expand Down
3 changes: 2 additions & 1 deletion packages/rn-sample/App.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// eslint-disable-next-line import/no-unassigned-import
import '@logto/rn/polyfill';

import { LogtoProvider, useLogto, type IdTokenClaims } from '@logto/rn';
import { LogtoProvider, Prompt, useLogto, type IdTokenClaims } from '@logto/rn';
import { StatusBar } from 'expo-status-bar';
import { useEffect, useState } from 'react';
import { Button, StyleSheet, Text, View } from 'react-native';
Expand Down Expand Up @@ -54,6 +54,7 @@ const App = () => {
config={{
endpoint,
appId,
prompt: Prompt.Login,
simeng-li marked this conversation as resolved.
Show resolved Hide resolved
}}
>
<Content />
Expand Down
6 changes: 5 additions & 1 deletion packages/rn-sample/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@
"dev": "expo start",
"dev:android": "expo start --android",
"dev:ios": "expo start --ios",
"dev:web": "expo start --web",
"android": "expo run:android",
"ios": "expo run:ios"
},
"dependencies": {
"@expo/metro-runtime": "~3.1.3",
"@logto/rn": "workspace:^",
"@react-native-async-storage/async-storage": "^1.22.0",
"expo": "~50.0.6",
Expand All @@ -19,7 +21,9 @@
"expo-status-bar": "~1.11.1",
"expo-web-browser": "^12.8.2",
"react": "18.2.0",
"react-native": "0.73.4"
"react-dom": "18.2.0",
"react-native": "0.73.4",
"react-native-web": "~0.19.6"
},
"devDependencies": {
"@babel/core": "^7.20.0",
Expand Down
3 changes: 2 additions & 1 deletion packages/rn/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"@react-native-async-storage/async-storage": "^1.22.0",
"expo-crypto": "^12.8.0",
"expo-secure-store": "^12.8.1",
"expo-web-browser": "^12.8.2"
"expo-web-browser": "^12.8.2",
"react-native": "0.73.4"
}
}
19 changes: 12 additions & 7 deletions packages/rn/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import {
type LogtoConfig,
createRequester,
LogtoError,
Prompt,
StandardLogtoClient,
createRequester,
type InteractionMode,
Prompt,
LogtoError,
type LogtoConfig,
} from '@logto/client/shim';
import { decodeIdToken } from '@logto/js';
import * as WebBrowser from 'expo-web-browser';
import { Platform } from 'react-native';

import { LogtoNativeClientError } from './errors';
import { SecureStorage } from './storage';
import { BrowserStorage, SecureStorage } from './storage';
import { generateCodeChallenge, generateRandomString } from './utils';

const issuedAtTimeTolerance = 300; // 5 minutes
Expand Down Expand Up @@ -39,10 +40,14 @@ export type LogtoNativeConfig = LogtoConfig & {

export class LogtoClient extends StandardLogtoClient {
authSessionResult?: WebBrowser.WebBrowserAuthSessionResult;
protected storage: SecureStorage;
protected storage: SecureStorage | BrowserStorage;

constructor(config: LogtoNativeConfig) {
const storage = new SecureStorage(`logto.${config.appId}`);
const storage =
Platform.OS === 'web'
? new BrowserStorage(config.appId)
: new SecureStorage(`logto.${config.appId}`);

const requester = createRequester(fetch);

super(
Expand Down
9 changes: 8 additions & 1 deletion packages/rn/src/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useCallback, useContext, useMemo } from 'react';
import { maybeCompleteAuthSession } from 'expo-web-browser';
import { useCallback, useContext, useEffect, useMemo } from 'react';

// eslint-disable-next-line unused-imports/no-unused-imports -- use for JSDoc
import type { LogtoClient } from './client';
Expand All @@ -12,6 +13,12 @@ import { LogtoContext } from './context';
export const useLogto = () => {
const { client, isAuthenticated, setIsAuthenticated } = useContext(LogtoContext);

useEffect(() => {
// This is required to handle the redirect from the browser on a web-based expo app
// @see {@link https://docs.expo.dev/versions/latest/sdk/webbrowser/#webbrowsermaybecompleteauthsessionoptions}
maybeCompleteAuthSession();
}, []);

const signIn = useCallback(
async (redirectUri: string) => {
await client.signIn(redirectUri);
Expand Down
50 changes: 49 additions & 1 deletion packages/rn/src/storage.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { type Storage } from '@logto/client/shim';
import { type Storage, type StorageKey } from '@logto/client/shim';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { Nullable } from '@silverhand/essentials';
import CryptoES from 'crypto-es';
import * as SecureStore from 'expo-secure-store';

Expand Down Expand Up @@ -69,3 +70,50 @@ export class SecureStorage implements Storage<string> {
return CryptoES.AES.decrypt(value, encryptionKey).toString(CryptoES.enc.Utf8);
}
}

const keyPrefix = `logto`;

/**
* This is a browser storage implementation that uses `localStorage` and `sessionStorage`.
*
* @remarks
* Forked from @logto/browser/src/storage.ts
* Since `expo-secure-store` doesn't support web, we need to use the browser's native storage.
simeng-li marked this conversation as resolved.
Show resolved Hide resolved
* @see {@link https://docs.expo.dev/versions/latest/sdk/securestore/}
*/
export class BrowserStorage implements Storage<StorageKey> {
constructor(public readonly appId: string) {}

getKey(item?: string) {
if (item === undefined) {
return `${keyPrefix}:${this.appId}`;
}

return `${keyPrefix}:${this.appId}:${item}`;
}

async getItem(key: StorageKey): Promise<Nullable<string>> {
if (key === 'signInSession') {
// The latter `getItem()` is for backward compatibility. Can be removed when major bump.
return sessionStorage.getItem(this.getKey(key)) ?? sessionStorage.getItem(this.getKey());
}

return localStorage.getItem(this.getKey(key));
}

async setItem(key: StorageKey, value: string): Promise<void> {
if (key === 'signInSession') {
sessionStorage.setItem(this.getKey(key), value);
return;
}
localStorage.setItem(this.getKey(key), value);
}

async removeItem(key: StorageKey): Promise<void> {
if (key === 'signInSession') {
sessionStorage.removeItem(this.getKey(key));
return;
}
localStorage.removeItem(this.getKey(key));
}
}
Loading
Loading