Skip to content

Commit

Permalink
Add WebApiService to handle generic access and refresh token logic (#541
Browse files Browse the repository at this point in the history
)
  • Loading branch information
lanedirt committed Jan 25, 2025
1 parent dd5f6f0 commit 7e80b67
Show file tree
Hide file tree
Showing 8 changed files with 247 additions and 76 deletions.
5 changes: 4 additions & 1 deletion browser-extensions/chrome/src/components/App.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import React from 'react';
import '../styles/tailwind.css';
import { DbProvider } from '../context/DbContext';
import { WebApiProvider } from '../context/WebApiContext';
import AppContent from './AppContent';

const App: React.FC = () => {
return (
<DbProvider>
<AppContent />
<WebApiProvider>
<AppContent />
</WebApiProvider>
</DbProvider>
);
};
Expand Down
24 changes: 11 additions & 13 deletions browser-extensions/chrome/src/components/Login.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import React, { useState } from 'react';
import Button from './Button';
import { Buffer } from 'buffer';
import { srpUtility } from '../utilities/SrpUtility';
import EncryptionUtility from '../utilities/EncryptionUtility';
import SqliteClient from '../utilities/SqliteClient';
import { useAuth } from '../context/AuthContext';
import { useDb } from '../context/DbContext';
import { useWebApi } from '../context/WebApiContext';
import SrpUtility from '../utilities/SrpUtility';

const Login: React.FC = () => {
const { login } = useAuth();
Expand All @@ -16,14 +16,17 @@ const Login: React.FC = () => {
const [rememberMe, setRememberMe] = useState(false);
const [error, setError] = useState<string | null>(null);
const dbContext = useDb();
const webApi = useWebApi();
// Create SrpUtility instance with webApi
const srpUtil = new SrpUtility(webApi);

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);

try {
// 1. Initiate login to get salt and server ephemeral
const loginResponse = await srpUtility.initiateLogin(credentials.username);
// Use the srpUtil instance instead of the imported singleton
const loginResponse = await srpUtil.initiateLogin(credentials.username);

// 1. Derive key from password using Argon2id
const passwordHash = await EncryptionUtility.deriveKeyFromPassword(
Expand All @@ -40,7 +43,7 @@ const Login: React.FC = () => {
console.log(passwordHashString);

// 2. Validate login with SRP protocol
const validationResponse = await srpUtility.validateLogin(
const validationResponse = await srpUtil.validateLogin(
credentials.username,
passwordHashString,
rememberMe,
Expand All @@ -50,19 +53,14 @@ const Login: React.FC = () => {
// Store access and refresh token using the context
if (validationResponse.token) {
// Store auth info
login(credentials.username, validationResponse.token.token, validationResponse.token.refreshToken);
await login(credentials.username, validationResponse.token.token, validationResponse.token.refreshToken);
} else {
throw new Error('Login failed -- no token returned');
}

// Make another API call trying to get latest vault
const vaultResponse = await fetch('https://localhost:7223/v1/Vault', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`
}
});

const vaultResponseJson = await vaultResponse.json();
// TODO: can we make webapi response typed?
const vaultResponseJson = await webApi.get('Vault') as any;

console.log('Vault response:')
console.log('--------------------------------');
Expand Down
15 changes: 7 additions & 8 deletions browser-extensions/chrome/src/components/Unlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@ import { Buffer } from 'buffer';
import EncryptionUtility from '../utilities/EncryptionUtility';
import { useDb } from '../context/DbContext';
import { useAuth } from '../context/AuthContext';
import { srpUtility } from '../utilities/SrpUtility';
import { useWebApi } from '../context/WebApiContext';
import SrpUtility from '../utilities/SrpUtility';

const Unlock: React.FC = () => {
const { username, logout } = useAuth();
const webApi = useWebApi();
// Create SrpUtility instance with webApi
const srpUtil = new SrpUtility(webApi);

const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
Expand All @@ -21,7 +25,7 @@ const Unlock: React.FC = () => {
// 1. Initiate login to get salt and server ephemeral
// TODO: adding encryption settings to vault response would save a call to the server.
// Check how WASM client does this? Should we store the settings in localstorage instead?
const loginResponse = await srpUtility.initiateLogin(username!);
const loginResponse = await srpUtil.initiateLogin(username!);

// Derive key from password using user's encryption settings
const passwordHash = await EncryptionUtility.deriveKeyFromPassword(
Expand All @@ -32,12 +36,7 @@ const Unlock: React.FC = () => {
);

// Make API call to get latest vault
const vaultResponse = await fetch('https://localhost:7223/v1/Vault', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('accessToken')}`
}
});
const vaultResponseJson = await vaultResponse.json();
const vaultResponseJson = await webApi.get('Vault') as any;

// Attempt to decrypt the blob
const passwordHashBase64 = Buffer.from(passwordHash).toString('base64');
Expand Down
52 changes: 34 additions & 18 deletions browser-extensions/chrome/src/context/AuthContext.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import React, { createContext, useContext, useState, useEffect, useRef } from 'react';

interface AuthContextType {
isLoggedIn: boolean;
username: string | null;
accessToken: string | null;
refreshToken: string | null;
login: (username: string, accessToken: string, refreshToken: string) => void;
logout: () => void;
login: (username: string, accessToken: string, refreshToken: string) => Promise<void>;
logout: () => Promise<void>;
getAccessToken: () => string | null;
getRefreshToken: () => string | null;
}

const AuthContext = createContext<AuthContextType | undefined>(undefined);
Expand All @@ -16,6 +16,8 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const [username, setUsername] = useState<string | null>(null);
const [accessToken, setAccessToken] = useState<string | null>(null);
const [refreshToken, setRefreshToken] = useState<string | null>(null);
const accessTokenRef = useRef<string | null>(null);

useEffect(() => {
// Check for tokens in localStorage on initial load
const storedAccessToken = localStorage.getItem('accessToken');
Expand All @@ -29,28 +31,42 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
}
}, []);

const login = (username: string, accessToken: string, refreshToken: string) => {
localStorage.setItem('username', username);
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
const login = async (username: string, accessToken: string, refreshToken: string) => {
accessTokenRef.current = accessToken; // Immediate update
await Promise.all([
localStorage.setItem('username', username),
localStorage.setItem('accessToken', accessToken),
localStorage.setItem('refreshToken', refreshToken),
]);

setUsername(username);
setAccessToken(accessToken);
setRefreshToken(refreshToken);
setIsLoggedIn(true);
};

const logout = () => {
localStorage.removeItem('username');
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
setUsername(null);
setAccessToken(null);
setRefreshToken(null);
setIsLoggedIn(false);
const logout = async () => {
await Promise.all([
localStorage.removeItem('username'),
localStorage.removeItem('accessToken'),
localStorage.removeItem('refreshToken'),
]);

await Promise.all([
setUsername(null),
setAccessToken(null),
setRefreshToken(null),
setIsLoggedIn(false),
]);
};

// Make sure to use the ref for accessToken and refreshToken to ensure
// that the latest values are used.
const getAccessToken = () => accessTokenRef.current || localStorage.getItem('accessToken');
const getRefreshToken = () => localStorage.getItem('refreshToken');

return (
<AuthContext.Provider value={{ isLoggedIn, username, accessToken, refreshToken, login, logout }}>
<AuthContext.Provider value={{ isLoggedIn, username, login, logout, getAccessToken, getRefreshToken }}>
{children}
</AuthContext.Provider>
);
Expand Down
32 changes: 32 additions & 0 deletions browser-extensions/chrome/src/context/WebApiContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import React, { createContext, useContext } from 'react';
import { WebApiService } from '../services/WebApiService';
import { useAuth } from './AuthContext';

const WebApiContext = createContext<WebApiService | null>(null);

export const WebApiProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { getAccessToken, getRefreshToken, login, logout } = useAuth();

const webApiService = new WebApiService(
() => getAccessToken(),
() => getRefreshToken(),
(newAccessToken, newRefreshToken) => {
login(username!, newAccessToken, newRefreshToken);
},
logout
);

return (
<WebApiContext.Provider value={webApiService}>
{children}
</WebApiContext.Provider>
);
};

export const useWebApi = () => {
const context = useContext(WebApiContext);
if (!context) {
throw new Error('useWebApi must be used within a WebApiProvider');
}
return context;
};
132 changes: 132 additions & 0 deletions browser-extensions/chrome/src/services/WebApiService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { Buffer } from 'buffer';

interface TokenResponse {
token: string;
refreshToken: string;
}

export class WebApiService {
private baseUrl: string = 'https://localhost:7223/v1/'; // Should be configurable

constructor(
private getAccessToken: () => string | null,
private getRefreshToken: () => string | null,
private updateTokens: (accessToken: string, refreshToken: string) => void,
private handleLogout: () => void
) {}

public async fetch<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = this.baseUrl + endpoint;
const headers = new Headers(options.headers || {});

// Add authorization header if we have an access token
const accessToken = this.getAccessToken();
console.log('accessToken in webapi:', accessToken);
if (accessToken) {
headers.set('Authorization', `Bearer ${accessToken}`);
}

const requestOptions: RequestInit = {
...options,
headers,
};

try {
const response = await fetch(url, requestOptions);

if (response.status === 401) {
const newToken = await this.refreshAccessToken();
if (newToken) {
headers.set('Authorization', `Bearer ${newToken}`);
const retryResponse = await fetch(url, {
...requestOptions,
headers,
});

if (!retryResponse.ok) {
throw new Error('Request failed after token refresh');
}

return retryResponse.json();
} else {
this.handleLogout();
throw new Error('Session expired');
}
}

if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}

return response.json();
} catch (error) {
console.error('API request failed:', error);
throw error;
}
}

private async refreshAccessToken(): Promise<string | null> {
const refreshToken = this.getRefreshToken();
if (!refreshToken) {
return null;
}

try {
const response = await fetch(`${this.baseUrl}Auth/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Ignore-Failure': 'true',
},
body: JSON.stringify({
token: this.getAccessToken(),
refreshToken: refreshToken,
}),
});

if (!response.ok) {
throw new Error('Failed to refresh token');
}

const tokenResponse: TokenResponse = await response.json();
this.updateTokens(tokenResponse.token, tokenResponse.refreshToken);
return tokenResponse.token;
} catch (error) {
console.error('Token refresh failed:', error);
this.handleLogout();
return null;
}
}

// Helper methods for common operations
public async get<T>(endpoint: string): Promise<T> {
return this.fetch<T>(endpoint, { method: 'GET' });
}

public async post<T>(endpoint: string, data: any): Promise<T> {
return this.fetch<T>(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
}

public async put<T>(endpoint: string, data: any): Promise<T> {
return this.fetch<T>(endpoint, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
}

public async delete<T>(endpoint: string): Promise<T> {
return this.fetch<T>(endpoint, { method: 'DELETE' });
}
}
Loading

0 comments on commit 7e80b67

Please sign in to comment.