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: Expo Web support (utilising localStorage) #8

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open
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
41 changes: 25 additions & 16 deletions lib/KindeAuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,12 @@ import {
} from "expo-auth-session";
import { openAuthSessionAsync } from "expo-web-browser";
import { createContext, useEffect, useState } from "react";
import { DEFAULT_TOKEN_SCOPES } from "./constants";
import { getStorage, setStorage, StorageKeys } from "./storage";
import {
DEFAULT_PLATFORM,
DEFAULT_TOKEN_SCOPES,
StorageKeys,
} from "./constants";
import StorageProvider from "./storage";
import {
LoginResponse,
LogoutRequest,
Expand All @@ -26,7 +30,7 @@ import { JWTDecoded, jwtDecoder } from "@kinde/jwt-decoder";
import Constants from "expo-constants";
import { decode, encode } from "base-64";
export const KindeAuthContext = createContext<KindeAuthHook | undefined>(
undefined,
undefined
);

// Polyfill for atob
Expand All @@ -42,6 +46,7 @@ export const KindeAuthProvider = ({
domain: string | undefined;
clientId: string | undefined;
scopes?: string;
platform?: "web" | "native";
};
}) => {
const domain = config.domain;
Expand All @@ -52,6 +57,10 @@ export const KindeAuthProvider = ({
if (clientId === undefined)
throw new Error("KindeAuthProvider config.clientId prop is undefined");

// Handle the storage provider based on platform.
const platform = config.platform || DEFAULT_PLATFORM;
const { getStorage, setStorage } = StorageProvider(platform);

const scopes = config.scopes?.split(" ") || DEFAULT_TOKEN_SCOPES.split(" ");

const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
Expand All @@ -73,7 +82,7 @@ export const KindeAuthProvider = ({
}, []);

const authenticate = async (
options: Partial<LoginMethodParams> = {},
options: Partial<LoginMethodParams> = {}
): Promise<LoginResponse> => {
if (!redirectUri) {
return {
Expand Down Expand Up @@ -106,7 +115,7 @@ export const KindeAuthProvider = ({
: undefined,
redirectUri,
},
discovery,
discovery
);

if (exchangeCodeResponse.idToken) {
Expand All @@ -117,12 +126,12 @@ export const KindeAuthProvider = ({
if (idTokenValidationResult.valid) {
await setStorage(
StorageKeys.idToken,
exchangeCodeResponse.idToken,
exchangeCodeResponse.idToken
);
} else {
console.error(
`Invalid id token`,
idTokenValidationResult.message,
idTokenValidationResult.message
);
}
}
Expand All @@ -134,13 +143,13 @@ export const KindeAuthProvider = ({
if (accessTokenValidationResult.valid) {
await setStorage(
StorageKeys.accessToken,
exchangeCodeResponse.accessToken,
exchangeCodeResponse.accessToken
);
setIsAuthenticated(true);
} else {
console.error(
`Invalid access token`,
accessTokenValidationResult.message,
accessTokenValidationResult.message
);
}

Expand All @@ -164,7 +173,7 @@ export const KindeAuthProvider = ({
* @returns {Promise<LoginResponse>}
*/
const login = async (
options: Partial<LoginMethodParams> = {},
options: Partial<LoginMethodParams> = {}
): Promise<LoginResponse> => {
return authenticate({ ...options, prompt: "login" });
};
Expand All @@ -175,7 +184,7 @@ export const KindeAuthProvider = ({
* @returns {Promise<LoginResponse>}
*/
const register = async (
options: Partial<LoginMethodParams> = {},
options: Partial<LoginMethodParams> = {}
): Promise<LoginResponse> => {
return authenticate({ ...options, prompt: "create" });
};
Expand All @@ -190,7 +199,7 @@ export const KindeAuthProvider = ({
}: Partial<LogoutRequest> = {}): Promise<LogoutResult> {
const endSession = async () => {
await openAuthSessionAsync(
`${discovery?.endSessionEndpoint}?redirect=${redirectUri}`,
`${discovery?.endSessionEndpoint}?redirect=${redirectUri}`
);
await setStorage(StorageKeys.accessToken, null);
await setStorage(StorageKeys.idToken, null);
Expand All @@ -203,7 +212,7 @@ export const KindeAuthProvider = ({
if (revokeToken) {
revokeAsync(
{ token: accesstoken!, tokenTypeHint: TokenTypeHint.AccessToken },
discovery,
discovery
)
.then(async () => {
await endSession();
Expand Down Expand Up @@ -263,7 +272,7 @@ export const KindeAuthProvider = ({
* @returns { PermissionAccess }
*/
async function getPermission(
permissionKey: string,
permissionKey: string
): Promise<PermissionAccess> {
const token = await getDecodedToken();

Expand Down Expand Up @@ -322,7 +331,7 @@ export const KindeAuthProvider = ({
* @returns { Promise<string | number | string[] | null> }
*/
async function getClaim<T = JWTDecoded, V = string | number | string[]>(
keyName: keyof T,
keyName: keyof T
): Promise<{
name: keyof T;
value: V;
Expand Down Expand Up @@ -358,7 +367,7 @@ export const KindeAuthProvider = ({
}

async function getFlag<T = string | boolean | number>(
name: string,
name: string
): Promise<T | null> {
const flags = (
await getClaim<
Expand Down
7 changes: 7 additions & 0 deletions lib/constants.ts
Original file line number Diff line number Diff line change
@@ -1 +1,8 @@
export const DEFAULT_TOKEN_SCOPES: string = "openid profile email offline";
export const DEFAULT_PLATFORM = "native";

export enum StorageKeys {
accessToken,
idToken,
state,
}
53 changes: 0 additions & 53 deletions lib/storage.ts

This file was deleted.

20 changes: 20 additions & 0 deletions lib/storage/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { DEFAULT_PLATFORM } from "../constants";
import { NativeStorageProvider } from "./nativeProvider";
import { IStorageProvider } from "./storageProvider.interface";
import { WebStorageProvider } from "./webProvider";

/**
* Storage provider factory
* @param {StorageKeys} platform Key to switch the storage provider
* @returns {Promise<void>}
*/
export default function StorageProvider(
platform: "web" | "native" = DEFAULT_PLATFORM
): IStorageProvider {
switch (platform) {
case "web":
return new WebStorageProvider();
default:
return new NativeStorageProvider();
}
}
43 changes: 43 additions & 0 deletions lib/storage/nativeProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { deleteItemAsync, getItemAsync, setItemAsync } from "expo-secure-store";
import { StorageKeys } from "../constants";
import { IStorageProvider } from "./storageProvider.interface";

/**
* Native storage provider (uses expo-secure-store)
*/
export class NativeStorageProvider implements IStorageProvider {
async setStorage(key: StorageKeys, value: string | null): Promise<void> {
if (!value) {
let index = 0;
let chunk = await getItemAsync(`${key}-${index}`);
while (chunk) {
await deleteItemAsync(`${key}-${index}`);
index++;
chunk = await getItemAsync(`${key}-${index}`);
}
return;
}
if (value.length > 2048) {
const chunks = value.match(/.{1,2048}/g);
if (chunks) {
chunks.forEach(async (chunk, index) => {
await setItemAsync(`${key}-${index}`, chunk);
});
}
Comment on lines +23 to +26
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Avoid using forEach with async callbacks.

Inside the forEach(async (chunk, index) => {...}), the calls to await setItemAsync won't resolve in a predictable sequence since Array.forEach doesn't await each iteration. If the order of execution matters, consider using a traditional for loop or Promise.all with map.

- chunks.forEach(async (chunk, index) => {
-   await setItemAsync(`${key}-${index}`, chunk);
- });
+ for (let i = 0; i < chunks.length; i++) {
+   await setItemAsync(`${key}-${i}`, chunks[i]);
+ }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
chunks.forEach(async (chunk, index) => {
await setItemAsync(`${key}-${index}`, chunk);
});
}
for (let i = 0; i < chunks.length; i++) {
await setItemAsync(`${key}-${i}`, chunks[i]);
}
}

} else {
await setItemAsync(`${key}-0`, value);
}
}

async getStorage(key: StorageKeys): Promise<string | null> {
const chunks = [];
let index = 0;
let chunk = await getItemAsync(`${key}-${index}`);
while (chunk) {
chunks.push(chunk);
index++;
chunk = await getItemAsync(`${key}-${index}`);
}
return chunks.join("");
}
}
18 changes: 18 additions & 0 deletions lib/storage/storageProvider.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { StorageKeys } from "../constants";

export interface IStorageProvider {
/**
* Sets item in the storage
* @param {StorageKeys} key Key to store the value
* @param {string} value value to store in the storage
* @returns {Promise<void>}
*/
setStorage(key: StorageKeys, value: string | null): Promise<void>;

/**
* Get item from the storage
* @param {StorageKeys} key Key to retrieve
* @returns {Promise<string | null>}
*/
getStorage(key: StorageKeys): Promise<string | null>;
}
22 changes: 22 additions & 0 deletions lib/storage/webProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { StorageKeys } from "../constants";
import { IStorageProvider } from "./storageProvider.interface";

/**
* Web storage provider (uses localStorage)
*/
export class WebStorageProvider implements IStorageProvider {
setStorage(key: StorageKeys, value: string | null): Promise<void> {
if (!value || value.length === 0) {
localStorage.removeItem(`${key}`);
return Promise.resolve();
}

localStorage.setItem(`${key}`, value);
return Promise.resolve();
}

getStorage(key: StorageKeys): Promise<string | null> {
const item = localStorage.getItem(`${key}`);
return Promise.resolve(item);
}
}
Comment on lines +7 to +22
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codebase verification

Security concern confirmed: Authentication tokens are stored in localStorage

The verification confirms that sensitive authentication data (access tokens and ID tokens) from Kinde authentication are being stored directly in localStorage:

  • StorageKeys.accessToken and StorageKeys.idToken are used to store authentication tokens
  • These tokens are stored unencrypted in localStorage through the WebStorageProvider
  • This implementation is vulnerable to XSS attacks as any malicious script running on the page can access these tokens

Recommendations:

  • Consider using HttpOnly cookies for token storage instead of localStorage
  • If localStorage must be used, implement additional security measures:
    • Encrypt tokens before storage
    • Implement token rotation
    • Add security headers like CSP to mitigate XSS risks
  • For the state parameter, which is less sensitive, localStorage usage is acceptable
🔗 Analysis chain

Be mindful of storing tokens in localStorage.
Storing access tokens, ID tokens, or refresh tokens in localStorage can introduce XSS vulnerabilities if malicious scripts gain access to the browser storage. Evaluate whether you need additional security measures, like secure cookies or token encryption, especially for production deployments.


Let me gather more information about how this storage is being used.

🏁 Scripts executed

The following scripts were executed for the analysis:

Script:

#!/bin/bash
# Searching project files for usage of WebStorageProvider to confirm in which contexts we rely on localStorage
rg -A 5 "WebStorageProvider"

Length of output: 959


Script:

#!/bin/bash
# Check what types of data are being stored using StorageKeys enum
rg -A 5 "enum StorageKeys"

# Search for actual usage of setStorage to understand what values are being stored
rg "setStorage\(" -A 3

Length of output: 1817

1 change: 1 addition & 0 deletions lib/useKindeAuth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useContext } from "react";

import {
LoginResponse,
LogoutResult,
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,11 @@
"expo-auth-session": "^5.5.2",
"expo-crypto": "^13.0.2",
"expo-linking": "^6.3.1",
"expo-secure-store": "^13.0.2"
"expo-secure-store": "^13.0.2",
"expo-web-browser": "~14.0.1"
},
"peerDependencies": {
"expo": "*",
"react-native": "^0.74.3"
"expo": "*"
},
"publishConfig": {
"access": "public"
Expand Down
Loading