proposal-system/mobile/src/lib/api/auth.ts
Adam Moussa 184bc1da7e Fix Phase 2 audit findings: reliability, UX, and operational monitoring
BLOCK-10: Add CloudWatch alarms (DLQ, Lambda errors, RDS, API 5xx) with SNS email
BLOCK-11: Remove sync-over-async deadlock in CurrentUserService
BLOCK-12: Add AppDelegate OAuth URL callback handler for mobile
BLOCK-13: Wire mobile 401 interceptor to dispatch Redux logout
BLOCK-14: Fix JWT base64 padding crash and SysAdmin role detection
BLOCK-15: Reset pagination to page 1 on filter change
BLOCK-16: Add unsaved-changes guard (beforeunload + useBlocker) to AdminWorkspace
FIX-08: Add BulkUpdateLineItems FluentValidation validator
FIX-13: Display auth errors on LoginPage
FIX-25: Add token refresh with retry queue to mobile API client
FIX-44: Add httpx retry logic to all Lambda handlers
FIX-42/43: Align docker-compose PG version (15) and DB name (proposals) with RDS
2026-05-20 19:07:49 -04:00

124 lines
3.6 KiB
TypeScript

import { authorize, refresh, revoke } from 'react-native-app-auth';
import Config from '../../config';
import apiClient, { registerTokenRefresh } from './client';
import { authenticateWithCredentials } from './cognito-auth';
import {
tokenStorage,
userStorage,
StoredTokens,
StoredUser,
} from '../storage';
function parseUserFromIdToken(idToken: string): StoredUser {
const base64Url = idToken.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '=');
const payload = JSON.parse(atob(padded));
const groups: string[] = payload['cognito:groups'] || [];
const role = groups.includes('sysadmins')
? 'SysAdmin'
: groups.includes('admins')
? 'Admin'
: 'Dispatcher';
return {
id: payload.sub,
email: payload.email,
displayName: payload.name || payload.email,
role: role as StoredUser['role'],
};
}
const cognitoConfig = {
clientId: Config.COGNITO_CLIENT_ID,
redirectUrl: Config.COGNITO_REDIRECT_URI,
scopes: Config.COGNITO_SCOPES,
serviceConfiguration: {
authorizationEndpoint: `https://${Config.COGNITO_DOMAIN}/oauth2/authorize`,
tokenEndpoint: `https://${Config.COGNITO_DOMAIN}/oauth2/token`,
revocationEndpoint: `https://${Config.COGNITO_DOMAIN}/oauth2/revoke`,
},
usePKCE: true,
iosPrefersEphemeralSession: false,
};
export const authApi = {
login: async (): Promise<StoredUser> => {
const result = await authorize(cognitoConfig);
const tokens: StoredTokens = {
accessToken: result.accessToken,
idToken: result.idToken,
refreshToken: result.refreshToken,
expiresAt: result.accessTokenExpirationDate,
};
await tokenStorage.save(tokens);
const profile = await authApi.getMe();
await userStorage.save(profile);
return profile;
},
loginWithCredentials: async (
email: string,
password: string,
): Promise<StoredUser> => {
const cognitoTokens = await authenticateWithCredentials(email, password);
const tokens: StoredTokens = {
accessToken: cognitoTokens.accessToken,
idToken: cognitoTokens.idToken,
refreshToken: cognitoTokens.refreshToken,
expiresAt: cognitoTokens.expiresAt,
};
await tokenStorage.save(tokens);
let profile: StoredUser;
try {
profile = await authApi.getMe();
} catch {
profile = parseUserFromIdToken(cognitoTokens.idToken);
}
await userStorage.save(profile);
return profile;
},
refreshTokens: async (): Promise<void> => {
const stored = await tokenStorage.get();
if (!stored?.refreshToken) throw new Error('No refresh token');
const result = await refresh(cognitoConfig, {
refreshToken: stored.refreshToken,
});
await tokenStorage.save({
accessToken: result.accessToken,
idToken: result.idToken || stored.idToken,
refreshToken: result.refreshToken || stored.refreshToken,
expiresAt: result.accessTokenExpirationDate,
});
},
logout: async (): Promise<void> => {
const stored = await tokenStorage.get();
if (stored?.accessToken) {
try {
await revoke(cognitoConfig, {
tokenToRevoke: stored.accessToken,
includeBasicAuth: false,
sendClientId: true,
});
} catch {
// Best-effort revocation
}
}
await tokenStorage.clear();
await userStorage.clear();
},
getMe: async (): Promise<StoredUser> => {
const res = await apiClient.get('/users/me');
return res.data;
},
};
registerTokenRefresh(() => authApi.refreshTokens());