diff --git a/packages/ilmomasiina-backend/src/routes/authentication/adminLogin.ts b/packages/ilmomasiina-backend/src/routes/authentication/adminLogin.ts index 10b7aced..5a1c9522 100644 --- a/packages/ilmomasiina-backend/src/routes/authentication/adminLogin.ts +++ b/packages/ilmomasiina-backend/src/routes/authentication/adminLogin.ts @@ -34,7 +34,7 @@ export function adminLogin(session: AdminAuthSession) { export function renewAdminToken(session: AdminAuthSession) { return async ( - request: FastifyRequest<{ Body: AdminLoginBody }>, + request: FastifyRequest, reply: FastifyReply, ): Promise => { // Verify existing token diff --git a/packages/ilmomasiina-backend/src/routes/index.ts b/packages/ilmomasiina-backend/src/routes/index.ts index 234425c1..a5b2c473 100644 --- a/packages/ilmomasiina-backend/src/routes/index.ts +++ b/packages/ilmomasiina-backend/src/routes/index.ts @@ -17,7 +17,7 @@ import deleteUser from './admin/users/deleteUser'; import inviteUser from './admin/users/inviteUser'; import listUsers from './admin/users/listUsers'; import resetPassword from './admin/users/resetPassword'; -import { adminLogin, requireAdmin } from './authentication/adminLogin'; +import { adminLogin, renewAdminToken, requireAdmin } from './authentication/adminLogin'; import { getEventDetailsForAdmin, getEventDetailsForUser } from './events/getEventDetails'; import { getEventsListForAdmin, getEventsListForUser } from './events/getEventsList'; import { sendICalFeed } from './ical'; @@ -323,7 +323,18 @@ async function setupPublicRoutes( adminLogin(opts.adminSession), ); - // TODO: Add an API endpoint for session token renewal as variant of adminLoginSchema + server.post( + '/authentication/renew', + { + schema: { + response: { + ...errorResponses, + 201: schema.adminLoginResponse, + }, + }, + }, + renewAdminToken(opts.adminSession), + ); // Public routes for events diff --git a/packages/ilmomasiina-components/src/api.ts b/packages/ilmomasiina-components/src/api.ts index b322278a..dc76819a 100644 --- a/packages/ilmomasiina-components/src/api.ts +++ b/packages/ilmomasiina-components/src/api.ts @@ -4,7 +4,6 @@ export interface FetchOptions { method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; body?: any; headers?: Record; - accessToken?: string; signal?: AbortSignal; } @@ -40,15 +39,12 @@ export function configureApi(url: string) { apiUrl = url; } -export default async function apiFetch(uri: string, { - method = 'GET', body, headers, accessToken, signal, +export default async function apiFetch(uri: string, { + method = 'GET', body, headers, signal, }: FetchOptions = {}) { const allHeaders = { ...headers || {}, }; - if (accessToken) { - allHeaders.Authorization = accessToken; - } if (body !== undefined) { allHeaders['Content-Type'] = 'application/json; charset=utf-8'; } @@ -68,10 +64,10 @@ export default async function apiFetch(uri: string, { } // 204 No Content if (response.status === 204) { - return null; + return null as T; } // just in case, convert JSON parse errors for 2xx responses to ApiError return response.json().catch((err) => { throw new ApiError(0, err); - }); + }) as Promise; } diff --git a/packages/ilmomasiina-components/src/contexts/auth.ts b/packages/ilmomasiina-components/src/contexts/auth.ts index 70862478..05a1a9c5 100644 --- a/packages/ilmomasiina-components/src/contexts/auth.ts +++ b/packages/ilmomasiina-components/src/contexts/auth.ts @@ -1,7 +1,6 @@ import { createContext } from 'react'; export interface AuthState { - accessToken?: string; loggedIn: boolean; } diff --git a/packages/ilmomasiina-frontend/src/api.ts b/packages/ilmomasiina-frontend/src/api.ts index 873fa406..9761ec19 100644 --- a/packages/ilmomasiina-frontend/src/api.ts +++ b/packages/ilmomasiina-frontend/src/api.ts @@ -1,14 +1,33 @@ import { ApiError, apiFetch, FetchOptions } from '@tietokilta/ilmomasiina-components'; import { ErrorCode } from '@tietokilta/ilmomasiina-models'; -import { loginExpired } from './modules/auth/actions'; +import { loginExpired, renewLogin } from './modules/auth/actions'; +import { AccessToken } from './modules/auth/types'; import type { DispatchAction } from './store/types'; +interface AdminApiFetchOptions extends FetchOptions { + accessToken?: AccessToken; +} + +const RENEW_LOGIN_THRESHOLD = 5 * 60 * 1000; + /** Wrapper for apiFetch that checks for Unauthenticated responses and dispatches a loginExpired * action if necessary. */ -export default async function adminApiFetch(uri: string, opts: FetchOptions, dispatch: DispatchAction) { +export default async function adminApiFetch( + uri: string, + opts: AdminApiFetchOptions, + dispatch: DispatchAction, +) { try { - return await apiFetch(uri, opts); + const { accessToken } = opts; + if (!accessToken) { + throw new ApiError(401, { isUnauthenticated: true }); + } + // Renew token asynchronously if it's expiring soon + if (Date.now() > accessToken.expiresAt - RENEW_LOGIN_THRESHOLD) { + dispatch(renewLogin(accessToken.token)); + } + return await apiFetch(uri, { ...opts, headers: { ...opts.headers, Authorization: accessToken.token } }); } catch (err) { if (err instanceof ApiError && err.code === ErrorCode.BAD_SESSION) { dispatch(loginExpired()); diff --git a/packages/ilmomasiina-frontend/src/containers/requireAuth.tsx b/packages/ilmomasiina-frontend/src/containers/requireAuth.tsx index 05a07d71..3ec0a794 100644 --- a/packages/ilmomasiina-frontend/src/containers/requireAuth.tsx +++ b/packages/ilmomasiina-frontend/src/containers/requireAuth.tsx @@ -9,11 +9,11 @@ export default function requireAuth

(WrappedComponent: ComponentTyp const RequireAuth = (props: P) => { const dispatch = useTypedDispatch(); - const { accessToken, accessTokenExpires } = useTypedSelector( + const { accessToken } = useTypedSelector( (state) => state.auth, ); - const expired = accessTokenExpires && new Date(accessTokenExpires) < new Date(); + const expired = accessToken && accessToken.expiresAt < Date.now(); const needLogin = expired || !accessToken; useEffect(() => { diff --git a/packages/ilmomasiina-frontend/src/modules/adminEvents/actions.ts b/packages/ilmomasiina-frontend/src/modules/adminEvents/actions.ts index 9cb5318b..b5a34b9c 100644 --- a/packages/ilmomasiina-frontend/src/modules/adminEvents/actions.ts +++ b/packages/ilmomasiina-frontend/src/modules/adminEvents/actions.ts @@ -28,8 +28,8 @@ export type AdminEventsActions = | ReturnType; export const getAdminEvents = () => async (dispatch: DispatchAction, getState: GetState) => { - const { accessToken } = getState().auth; try { + const { accessToken } = getState().auth; const response = await adminApiFetch('admin/events', { accessToken }, dispatch); dispatch(eventsLoaded(response as AdminEventListResponse)); } catch (e) { diff --git a/packages/ilmomasiina-frontend/src/modules/auth/actions.ts b/packages/ilmomasiina-frontend/src/modules/auth/actions.ts index 72ff4f23..e9f0fcc9 100644 --- a/packages/ilmomasiina-frontend/src/modules/auth/actions.ts +++ b/packages/ilmomasiina-frontend/src/modules/auth/actions.ts @@ -1,8 +1,8 @@ import { push } from 'connected-react-router'; import { toast } from 'react-toastify'; -import { apiFetch } from '@tietokilta/ilmomasiina-components'; -import type { AdminLoginResponse } from '@tietokilta/ilmomasiina-models'; +import { ApiError, apiFetch } from '@tietokilta/ilmomasiina-components'; +import { AdminLoginResponse, ErrorCode } from '@tietokilta/ilmomasiina-models'; import i18n from '../../i18n'; import appPaths from '../../paths'; import type { DispatchAction } from '../../store/types'; @@ -36,13 +36,13 @@ const loginToast = (type: 'success' | 'error', text: string, autoClose: number) }; export const login = (email: string, password: string) => async (dispatch: DispatchAction) => { - const sessionResponse = await apiFetch('authentication', { + const sessionResponse = await apiFetch('authentication', { method: 'POST', body: { email, password, }, - }) as AdminLoginResponse; + }); dispatch(loginSucceeded(sessionResponse)); dispatch(push(appPaths.adminEventsList)); loginToast('success', i18n.t('auth.loginSuccess'), 2000); @@ -78,3 +78,28 @@ export const loginExpired = () => (dispatch: DispatchAction) => { loginToast('error', i18n.t('auth.loginExpired'), 10000); dispatch(redirectToLogin()); }; + +export const renewLogin = (accessToken: string) => async (dispatch: DispatchAction) => { + try { + if (accessToken) { + const sessionResponse = await apiFetch('authentication/renew', { + method: 'POST', + body: { + accessToken, + }, + headers: { + Authorization: accessToken, + }, + }); + if (sessionResponse) { + dispatch(loginSucceeded(sessionResponse)); + } + } + } catch (err) { + if (err instanceof ApiError && err.code === ErrorCode.BAD_SESSION) { + dispatch(loginExpired()); + } else { + throw err; + } + } +}; diff --git a/packages/ilmomasiina-frontend/src/modules/auth/reducer.ts b/packages/ilmomasiina-frontend/src/modules/auth/reducer.ts index 9c441341..d169fa77 100644 --- a/packages/ilmomasiina-frontend/src/modules/auth/reducer.ts +++ b/packages/ilmomasiina-frontend/src/modules/auth/reducer.ts @@ -1,29 +1,26 @@ -import moment, { Moment } from 'moment'; - import { LOGIN_SUCCEEDED, RESET } from './actionTypes'; import type { AuthActions, AuthState } from './types'; const initialState: AuthState = { accessToken: undefined, - accessTokenExpires: undefined, loggedIn: false, }; -function getTokenExpiry(jwt: string): Moment { +function getTokenExpiry(jwt: string): number { const parts = jwt.split('.'); try { const payload = JSON.parse(window.atob(parts[1])); if (payload.exp) { - return moment.unix(payload.exp); + return payload.exp * 1000; } } catch { // eslint-disable-next-line no-console console.error('Invalid jwt token received!'); } - return moment(); + return 0; } export default function reducer( @@ -35,8 +32,10 @@ export default function reducer( return initialState; case LOGIN_SUCCEEDED: return { - accessToken: action.payload.accessToken, - accessTokenExpires: getTokenExpiry(action.payload.accessToken).toISOString(), + accessToken: { + token: action.payload.accessToken, + expiresAt: getTokenExpiry(action.payload.accessToken), + }, loggedIn: true, }; default: diff --git a/packages/ilmomasiina-frontend/src/modules/auth/types.ts b/packages/ilmomasiina-frontend/src/modules/auth/types.ts index edde1e53..cd227f18 100644 --- a/packages/ilmomasiina-frontend/src/modules/auth/types.ts +++ b/packages/ilmomasiina-frontend/src/modules/auth/types.ts @@ -1,6 +1,9 @@ +export interface AccessToken { + token: string; + expiresAt: number; // Unix timestamp +} export interface AuthState { - accessToken?: string; - accessTokenExpires?: string; + accessToken?: AccessToken; loggedIn: boolean; } diff --git a/packages/ilmomasiina-models/src/schema/login/index.ts b/packages/ilmomasiina-models/src/schema/login/index.ts index 8b620460..23e492d0 100644 --- a/packages/ilmomasiina-models/src/schema/login/index.ts +++ b/packages/ilmomasiina-models/src/schema/login/index.ts @@ -9,7 +9,6 @@ export const adminLoginBody = Type.Object({ description: 'Plaintext password.', }), }); - /** Response schema for a successful login. */ export const adminLoginResponse = Type.Object({ accessToken: Type.String({