feat: implement long-lived sessions

This commit is contained in:
Julian Tölle 2020-09-05 23:35:53 +02:00
parent d0705afca8
commit 44f7e26270
35 changed files with 739 additions and 190 deletions

View file

@ -2964,6 +2964,14 @@
"resolved": "https://registry.npmjs.org/aws4/-/aws4-1.10.1.tgz", "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.10.1.tgz",
"integrity": "sha512-zg7Hz2k5lI8kb7U32998pRRFin7zJlkfezGJjUc2heaD4Pw2wObakCDVzkKztTm/Ln7eiVvYsjqak0Ed4LkMDA==" "integrity": "sha512-zg7Hz2k5lI8kb7U32998pRRFin7zJlkfezGJjUc2heaD4Pw2wObakCDVzkKztTm/Ln7eiVvYsjqak0Ed4LkMDA=="
}, },
"axios": {
"version": "0.21.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.0.tgz",
"integrity": "sha512-fmkJBknJKoZwem3/IKSSLpkdNXZeBu5Q7GA/aRsr2btgrptmSCxi2oFjZHqGdK9DoTil9PIHlPIZw2EcRJXRvw==",
"requires": {
"follow-redirects": "^1.10.0"
}
},
"axobject-query": { "axobject-query": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz",

View file

@ -19,6 +19,7 @@
"@types/react-router-dom": "5.1.5", "@types/react-router-dom": "5.1.5",
"@types/recharts": "1.8.15", "@types/recharts": "1.8.15",
"autoprefixer": "9.8.6", "autoprefixer": "9.8.6",
"axios": "^0.21.0",
"date-fns": "2.16.1", "date-fns": "2.16.1",
"npm-run-all": "4.1.5", "npm-run-all": "4.1.5",
"postcss-cli": "7.1.1", "postcss-cli": "7.1.1",

View file

@ -1,5 +1,5 @@
import { AxiosInstance } from "axios";
import { formatISO, parseISO } from "date-fns"; import { formatISO, parseISO } from "date-fns";
import { qs } from "../util/queryString";
import { Listen } from "./entities/listen"; import { Listen } from "./entities/listen";
import { ListenReportItem } from "./entities/listen-report-item"; import { ListenReportItem } from "./entities/listen-report-item";
import { ListenReportOptions } from "./entities/listen-report-options"; import { ListenReportOptions } from "./entities/listen-report-options";
@ -7,58 +7,18 @@ import { Pagination } from "./entities/pagination";
import { PaginationOptions } from "./entities/pagination-options"; import { PaginationOptions } from "./entities/pagination-options";
import { TopArtistsItem } from "./entities/top-artists-item"; import { TopArtistsItem } from "./entities/top-artists-item";
import { TopArtistsOptions } from "./entities/top-artists-options"; import { TopArtistsOptions } from "./entities/top-artists-options";
import { User } from "./entities/user";
export class UnauthenticatedError extends Error {} export class UnauthenticatedError extends Error {}
const getToken = (): string => {
const cookieValue = document.cookie.replace(
/(?:(?:^|.*;\s*)listory_access_token\s*=\s*([^;]*).*$)|^.*$/,
"$1"
);
return cookieValue;
};
const getDefaultHeaders = (): Headers => {
const headers = new Headers();
headers.append("Content-Type", "application/json");
headers.append("Authorization", `Bearer ${getToken()}`);
return headers;
};
export const getUsersMe = async (): Promise<User> => {
const res = await fetch(`/api/v1/users/me`, { headers: getDefaultHeaders() });
switch (res.status) {
case 200: {
break;
}
case 401: {
throw new UnauthenticatedError(`No token or token expired`);
}
default: {
throw new Error(`Unable to getUsersMe: ${res.status}`);
}
}
const user: User = await res.json();
return user;
};
export const getRecentListens = async ( export const getRecentListens = async (
options: PaginationOptions = { page: 1, limit: 10 } options: PaginationOptions = { page: 1, limit: 10 },
client: AxiosInstance
): Promise<Pagination<Listen>> => { ): Promise<Pagination<Listen>> => {
const { page, limit } = options; const { page, limit } = options;
const res = await fetch( const res = await client.get<Pagination<Listen>>(`/api/v1/listens`, {
`/api/v1/listens?${qs({ page: page.toString(), limit: limit.toString() })}`, params: { page, limit },
{ });
headers: getDefaultHeaders(),
}
);
switch (res.status) { switch (res.status) {
case 200: { case 200: {
@ -72,27 +32,27 @@ export const getRecentListens = async (
} }
} }
const listens: Pagination<Listen> = await res.json(); return res.data;
return listens;
}; };
export const getListensReport = async ( export const getListensReport = async (
options: ListenReportOptions options: ListenReportOptions,
client: AxiosInstance
): Promise<ListenReportItem[]> => { ): Promise<ListenReportItem[]> => {
const { const {
timeFrame, timeFrame,
time: { timePreset, customTimeStart, customTimeEnd }, time: { timePreset, customTimeStart, customTimeEnd },
} = options; } = options;
const res = await fetch( const res = await client.get<{ items: { count: number; date: string }[] }>(
`/api/v1/reports/listens?${qs({ `/api/v1/reports/listens`,
timeFrame,
timePreset,
customTimeStart: formatISO(customTimeStart),
customTimeEnd: formatISO(customTimeEnd),
})}`,
{ {
headers: getDefaultHeaders(), params: {
timeFrame,
timePreset,
customTimeStart: formatISO(customTimeStart),
customTimeEnd: formatISO(customTimeEnd),
},
} }
); );
@ -108,25 +68,28 @@ export const getListensReport = async (
} }
} }
const rawItems: { count: number; date: string }[] = (await res.json()).items; const {
data: { items: rawItems },
} = res;
return rawItems.map(({ count, date }) => ({ count, date: parseISO(date) })); return rawItems.map(({ count, date }) => ({ count, date: parseISO(date) }));
}; };
export const getTopArtists = async ( export const getTopArtists = async (
options: TopArtistsOptions options: TopArtistsOptions,
client: AxiosInstance
): Promise<TopArtistsItem[]> => { ): Promise<TopArtistsItem[]> => {
const { const {
time: { timePreset, customTimeStart, customTimeEnd }, time: { timePreset, customTimeStart, customTimeEnd },
} = options; } = options;
const res = await fetch( const res = await client.get<{ items: TopArtistsItem[] }>(
`/api/v1/reports/top-artists?${qs({ `/api/v1/reports/top-artists`,
timePreset,
customTimeStart: formatISO(customTimeStart),
customTimeEnd: formatISO(customTimeEnd),
})}`,
{ {
headers: getDefaultHeaders(), params: {
timePreset,
customTimeStart: formatISO(customTimeStart),
customTimeEnd: formatISO(customTimeEnd),
},
} }
); );
@ -142,6 +105,8 @@ export const getTopArtists = async (
} }
} }
const items: TopArtistsItem[] = (await res.json()).items; const {
data: { items },
} = res;
return items; return items;
}; };

View file

@ -0,0 +1,56 @@
/*
* These calls are seperate from the others because they are only
* used in the useAuth hook which is used before the useApiClient hook.
*
* They do not use the apiClient/axios.
*/
import { UnauthenticatedError } from "./api";
import { RefreshTokenResponse } from "./entities/refresh-token-response";
import { User } from "./entities/user";
export const getUsersMe = async (accessToken: string): Promise<User> => {
const res = await fetch(`/api/v1/users/me`, {
headers: new Headers({
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
}),
});
switch (res.status) {
case 200: {
break;
}
case 401: {
throw new UnauthenticatedError(`No token or token expired`);
}
default: {
throw new Error(`Unable to getUsersMe: ${res.status}`);
}
}
const user: User = await res.json();
return user;
};
export const postAuthTokenRefresh = async (): Promise<RefreshTokenResponse> => {
const res = await fetch(`/api/v1/auth/token/refresh`, {
method: "POST",
headers: new Headers({ "Content-Type": "application/json" }),
});
switch (res.status) {
case 201: {
break;
}
case 401: {
throw new UnauthenticatedError(`No Refresh Token or token expired`);
}
default: {
throw new Error(`Unable to postAuthTokenRefresh: ${res.status}`);
}
}
const refreshTokenResponse: RefreshTokenResponse = await res.json();
return refreshTokenResponse;
};

View file

@ -0,0 +1,3 @@
export interface RefreshTokenResponse {
accessToken: string;
}

View file

@ -1,8 +1,8 @@
import { format, formatDistanceToNow } from "date-fns"; import { format, formatDistanceToNow } from "date-fns";
import React, { useEffect, useState, useMemo } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { Redirect } from "react-router-dom"; import { Redirect } from "react-router-dom";
import { getRecentListens } from "../api/api";
import { Listen } from "../api/entities/listen"; import { Listen } from "../api/entities/listen";
import { useRecentListens } from "../hooks/use-api";
import { useAuth } from "../hooks/use-auth"; import { useAuth } from "../hooks/use-auth";
import { ReloadIcon } from "../icons/Reload"; import { ReloadIcon } from "../icons/Reload";
import { getPaginationItems } from "../util/getPaginationItems"; import { getPaginationItems } from "../util/getPaginationItems";
@ -14,35 +14,18 @@ export const RecentListens: React.FC = () => {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
const [listens, setListens] = useState<Listen[]>([]);
const [isLoading, setIsLoading] = useState(true);
const loadListensForPage = useMemo( const options = useMemo(() => ({ page, limit: LISTENS_PER_PAGE }), [page]);
() => async () => {
setIsLoading(true);
try { const { recentListens, paginationMeta, isLoading, reload } = useRecentListens(
const listensFromApi = await getRecentListens({ options
page,
limit: LISTENS_PER_PAGE,
});
if (totalPages !== listensFromApi.meta.totalPages) {
setTotalPages(listensFromApi.meta.totalPages);
}
setListens(listensFromApi.items);
} catch (err) {
console.error("Error while fetching recent listens:", err);
} finally {
setIsLoading(false);
}
},
[page, totalPages, setIsLoading, setTotalPages, setListens]
); );
useEffect(() => { useEffect(() => {
loadListensForPage(); if (paginationMeta && totalPages !== paginationMeta.totalPages) {
}, [loadListensForPage]); setTotalPages(paginationMeta.totalPages);
}
}, [totalPages, paginationMeta]);
if (!user) { if (!user) {
return <Redirect to="/" />; return <Redirect to="/" />;
@ -55,7 +38,7 @@ export const RecentListens: React.FC = () => {
<p className="text-2xl font-normal text-gray-700">Recent listens</p> <p className="text-2xl font-normal text-gray-700">Recent listens</p>
<button <button
className="flex-shrink-0 mx-2 bg-transparent hover:bg-green-500 text-green-500 hover:text-white font-semibold py-2 px-4 border border-green-500 hover:border-transparent rounded" className="flex-shrink-0 mx-2 bg-transparent hover:bg-green-500 text-green-500 hover:text-white font-semibold py-2 px-4 border border-green-500 hover:border-transparent rounded"
onClick={loadListensForPage} onClick={reload}
> >
<ReloadIcon className="w-5 h-5 fill-current" /> <ReloadIcon className="w-5 h-5 fill-current" />
</button> </button>
@ -66,14 +49,14 @@ export const RecentListens: React.FC = () => {
<span>Loading Listens</span> <span>Loading Listens</span>
</div> </div>
)} )}
{listens.length === 0 && ( {recentListens.length === 0 && (
<div> <div>
<p>Could not find any listens!</p> <p>Could not find any listens!</p>
</div> </div>
)} )}
{listens.length > 0 && ( {recentListens.length > 0 && (
<div className="table-auto my-2 w-full text-gray-700"> <div className="table-auto my-2 w-full text-gray-700">
{listens.map((listen) => ( {recentListens.map((listen) => (
<ListenItem listen={listen} key={listen.id} /> <ListenItem listen={listen} key={listen.id} />
))} ))}
</div> </div>

View file

@ -11,12 +11,11 @@ import {
XAxis, XAxis,
YAxis, YAxis,
} from "recharts"; } from "recharts";
import { getListensReport } from "../api/api";
import { ListenReportItem } from "../api/entities/listen-report-item"; import { ListenReportItem } from "../api/entities/listen-report-item";
import { ListenReportOptions } from "../api/entities/listen-report-options"; import { ListenReportOptions } from "../api/entities/listen-report-options";
import { TimeOptions } from "../api/entities/time-options"; import { TimeOptions } from "../api/entities/time-options";
import { TimePreset } from "../api/entities/time-preset.enum"; import { TimePreset } from "../api/entities/time-preset.enum";
import { useAsync } from "../hooks/use-async"; import { useListensReport } from "../hooks/use-api";
import { useAuth } from "../hooks/use-auth"; import { useAuth } from "../hooks/use-auth";
import { ReportTimeOptions } from "./ReportTimeOptions"; import { ReportTimeOptions } from "./ReportTimeOptions";
@ -33,12 +32,12 @@ export const ReportListens: React.FC = () => {
customTimeEnd: new Date(), customTimeEnd: new Date(),
}); });
const fetchData = useMemo( const reportOptions = useMemo(() => ({ timeFrame, time: timeOptions }), [
() => () => getListensReport({ timeFrame, time: timeOptions }), timeFrame,
[timeFrame, timeOptions] timeOptions,
); ]);
const { value: report, pending: isLoading } = useAsync(fetchData, []); const { report, isLoading } = useListensReport(reportOptions);
const reportHasItems = !isLoading && report.length !== 0; const reportHasItems = !isLoading && report.length !== 0;

View file

@ -1,15 +1,11 @@
import React, { useMemo, useState } from "react"; import React, { useMemo, useState } from "react";
import { Redirect } from "react-router-dom"; import { Redirect } from "react-router-dom";
import { getTopArtists } from "../api/api";
import { TimeOptions } from "../api/entities/time-options"; import { TimeOptions } from "../api/entities/time-options";
import { TimePreset } from "../api/entities/time-preset.enum"; import { TimePreset } from "../api/entities/time-preset.enum";
import { TopArtistsItem } from "../api/entities/top-artists-item"; import { useTopArtists } from "../hooks/use-api";
import { useAsync } from "../hooks/use-async";
import { useAuth } from "../hooks/use-auth"; import { useAuth } from "../hooks/use-auth";
import { ReportTimeOptions } from "./ReportTimeOptions"; import { ReportTimeOptions } from "./ReportTimeOptions";
const INITIAL_REPORT_DATA: TopArtistsItem[] = [];
export const ReportTopArtists: React.FC = () => { export const ReportTopArtists: React.FC = () => {
const { user } = useAuth(); const { user } = useAuth();
@ -19,16 +15,16 @@ export const ReportTopArtists: React.FC = () => {
customTimeEnd: new Date(), customTimeEnd: new Date(),
}); });
const fetchData = useMemo(() => () => getTopArtists({ time: timeOptions }), [ const options = useMemo(
timeOptions, () => ({
]); time: timeOptions,
}),
const { value: report, pending: isLoading } = useAsync( [timeOptions]
fetchData,
INITIAL_REPORT_DATA
); );
const reportHasItems = !isLoading && report.length !== 0; const { topArtists, isLoading } = useTopArtists(options);
const reportHasItems = !isLoading && topArtists.length !== 0;
if (!user) { if (!user) {
return <Redirect to="/" />; return <Redirect to="/" />;
@ -56,7 +52,7 @@ export const ReportTopArtists: React.FC = () => {
</div> </div>
)} )}
{reportHasItems && {reportHasItems &&
report.map(({ artist, count }) => ( topArtists.map(({ artist, count }) => (
<div key={artist.id}> <div key={artist.id}>
{count} - {artist.name} {count} - {artist.name}
</div> </div>

View file

@ -0,0 +1,95 @@
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios";
import React, {
createContext,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { useAuth } from "./use-auth";
interface ApiClientContext {
client: AxiosInstance;
}
const apiClientContext = createContext<ApiClientContext>(
(undefined as any) as ApiClientContext
);
export const ProvideApiClient: React.FC = ({ children }) => {
const auth = useProvideApiClient();
return (
<apiClientContext.Provider value={auth}>
{children}
</apiClientContext.Provider>
);
};
export function useApiClient() {
return useContext(apiClientContext);
}
function useProvideApiClient(): ApiClientContext {
const { accessToken, refreshAccessToken } = useAuth();
// Wrap value to immediatly update when refreshing access token
// and always having access to newest access token in interceptor
const localAccessToken = useRef(accessToken);
// initialState must be passed as function as return value of axios.create()
// is also callable and react will call it and then use that result (promise)
// as the initial state instead of the axios instace.
const [client] = useState<AxiosInstance>(() => axios.create());
useEffect(() => {
localAccessToken.current = accessToken;
}, [localAccessToken, accessToken]);
// TODO Implement lock to avoid multiple parallel refreshes, maybe in useAuth?
useEffect(() => {
// Setup Axios Interceptors
const requestInterceptor = client.interceptors.request.use(
(config) => {
if (!config.headers) {
config.headers = {};
}
config.headers.Authorization = `Bearer ${localAccessToken.current}`;
return config;
},
(err) => Promise.reject(err)
);
const responseInterceptor = client.interceptors.response.use(
(data) => data,
async (err: any) => {
if (!err.response || !err.config) {
throw err;
}
const { response, config } = err as {
response: AxiosResponse;
config: AxiosRequestConfig;
};
if (response && response.status !== 401) {
throw err;
}
// TODO error handling
localAccessToken.current = await refreshAccessToken();
return client.request(config);
}
);
return () => {
client.interceptors.request.eject(requestInterceptor);
client.interceptors.response.eject(responseInterceptor);
};
}, [client, localAccessToken, refreshAccessToken]);
return { client };
}

View file

@ -0,0 +1,65 @@
import { useMemo, useState } from "react";
import { getListensReport, getRecentListens, getTopArtists } from "../api/api";
import { ListenReportOptions } from "../api/entities/listen-report-options";
import { PaginationOptions } from "../api/entities/pagination-options";
import { TopArtistsOptions } from "../api/entities/top-artists-options";
import { useApiClient } from "./use-api-client";
import { useAsync } from "./use-async";
const INITIAL_EMPTY_ARRAY: [] = [];
Object.freeze(INITIAL_EMPTY_ARRAY);
export const useRecentListens = (options: PaginationOptions) => {
const { client } = useApiClient();
const fetchData = useMemo(() => () => getRecentListens(options, client), [
options,
client,
]);
const { value, pending: isLoading, error, reload } = useAsync(
fetchData,
undefined
);
const recentListens = value ? value.items : [];
const paginationMeta = value ? value.meta : undefined;
return { recentListens, paginationMeta, isLoading, error, reload };
};
export const useListensReport = (options: ListenReportOptions) => {
const { client } = useApiClient();
const [initialData] = useState(INITIAL_EMPTY_ARRAY);
const fetchData = useMemo(() => () => getListensReport(options, client), [
options,
client,
]);
const { value: report, pending: isLoading, error } = useAsync(
fetchData,
initialData
);
return { report, isLoading, error };
};
export const useTopArtists = (options: TopArtistsOptions) => {
const { client } = useApiClient();
const [initialData] = useState(INITIAL_EMPTY_ARRAY);
const fetchData = useMemo(() => () => getTopArtists(options, client), [
options,
client,
]);
const { value: topArtists, pending: isLoading, error } = useAsync(
fetchData,
initialData
);
return { topArtists, isLoading, error };
};

View file

@ -3,7 +3,12 @@ import { useCallback, useEffect, useState } from "react";
type UseAsync = <T>( type UseAsync = <T>(
asyncFunction: () => Promise<T>, asyncFunction: () => Promise<T>,
initialValue: T initialValue: T
) => { pending: boolean; value: T; error: Error | null }; ) => {
pending: boolean;
value: T;
error: Error | null;
reload: () => Promise<void>;
};
export const useAsync: UseAsync = <T extends any>( export const useAsync: UseAsync = <T extends any>(
asyncFunction: () => Promise<T>, asyncFunction: () => Promise<T>,
@ -34,5 +39,5 @@ export const useAsync: UseAsync = <T extends any>(
execute(); execute();
}, [execute]); }, [execute]);
return { execute, pending, value, error }; return { reload: execute, pending, value, error };
}; };

View file

@ -1,20 +1,26 @@
import React, { createContext, useContext, useEffect, useState } from "react"; import React, {
import { getUsersMe, UnauthenticatedError } from "../api/api"; createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import { getUsersMe, postAuthTokenRefresh } from "../api/auth-api";
import { User } from "../api/entities/user"; import { User } from "../api/entities/user";
interface AuthContext {
isLoaded: boolean;
user: User | null;
accessToken: string;
error: Error | null;
refreshAccessToken: () => Promise<string>;
loginWithSpotifyProps: () => { href: string };
}
const authContext = createContext<AuthContext>( const authContext = createContext<AuthContext>(
(undefined as any) as AuthContext (undefined as any) as AuthContext
); );
// Provider component that wraps your app and makes auth object ...
// ... available to any child component that calls useAuth().
interface AuthContext {
user: { id: string; displayName: string } | null;
isLoaded: boolean;
loginWithSpotifyProps: () => { href: string };
}
export const ProvideAuth: React.FC = ({ children }) => { export const ProvideAuth: React.FC = ({ children }) => {
const auth = useProvideAuth(); const auth = useProvideAuth();
@ -28,25 +34,51 @@ export function useAuth() {
function useProvideAuth(): AuthContext { function useProvideAuth(): AuthContext {
const [isLoaded, setIsLoaded] = useState(false); const [isLoaded, setIsLoaded] = useState(false);
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [accessToken, setAccessToken] = useState<string>("");
const [error, setError] = useState<Error | null>(null);
const loginWithSpotifyProps = () => ({ href: "/api/v1/auth/spotify" }); const loginWithSpotifyProps = () => ({ href: "/api/v1/auth/spotify" });
useEffect(() => { const refreshAccessToken = useCallback(async () => {
(async () => { try {
try { const { accessToken: newAccessToken } = await postAuthTokenRefresh();
const currentUser = await getUsersMe(); setAccessToken(newAccessToken);
setUser(currentUser); return newAccessToken;
} catch (err) { } catch (err) {
if (err instanceof UnauthenticatedError) { setAccessToken("");
// User is not logged in setUser(null);
} else { setIsLoaded(true);
console.error("Error while checking login state:", err); setError(err);
}
} finally { throw err;
setIsLoaded(true); }
}
})();
}, []); }, []);
return { isLoaded, user, loginWithSpotifyProps }; useEffect(() => {
refreshAccessToken().catch(() => {
console.log("Unable to refresh access token");
});
}, [refreshAccessToken]);
useEffect(() => {
if (!accessToken) {
return;
}
async function getUser(token: string) {
const newUser = await getUsersMe(token);
setUser(newUser);
setIsLoaded(true);
}
getUser(accessToken);
}, [accessToken]);
return {
isLoaded,
user,
accessToken,
error,
refreshAccessToken,
loginWithSpotifyProps,
};
} }

View file

@ -2,15 +2,18 @@ import React from "react";
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import { App } from "./App"; import { App } from "./App";
import { ProvideApiClient } from "./hooks/use-api-client";
import { ProvideAuth } from "./hooks/use-auth"; import { ProvideAuth } from "./hooks/use-auth";
import * as serviceWorker from "./serviceWorker"; import * as serviceWorker from "./serviceWorker";
ReactDOM.render( ReactDOM.render(
<React.StrictMode> <React.StrictMode>
<ProvideAuth> <ProvideAuth>
<BrowserRouter> <ProvideApiClient>
<App /> <BrowserRouter>
</BrowserRouter> <App />
</BrowserRouter>
</ProvideApiClient>
</ProvideAuth> </ProvideAuth>
</React.StrictMode>, </React.StrictMode>,
document.getElementById("root") document.getElementById("root")

18
package-lock.json generated
View file

@ -1820,6 +1820,15 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"@types/cookie-parser": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.2.tgz",
"integrity": "sha512-uwcY8m6SDQqciHsqcKDGbo10GdasYsPCYkH3hVegj9qAah6pX5HivOnOuI3WYmyQMnOATV39zv/Ybs0bC/6iVg==",
"dev": true,
"requires": {
"@types/express": "*"
}
},
"@types/cookiejar": { "@types/cookiejar": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.1.tgz", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.1.tgz",
@ -3635,6 +3644,15 @@
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz",
"integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==" "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg=="
}, },
"cookie-parser": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.5.tgz",
"integrity": "sha512-f13bPUj/gG/5mDr+xLmSxxDsB9DQiTIfhJS/sqjrmfAWiAN+x2O4i/XguTL9yDZ+/IFDanJ+5x7hC4CXT9Tdzw==",
"requires": {
"cookie": "0.4.0",
"cookie-signature": "1.0.6"
}
},
"cookie-signature": { "cookie-signature": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",

View file

@ -37,6 +37,7 @@
"@nestjs/typeorm": "7.1.0", "@nestjs/typeorm": "7.1.0",
"class-transformer": "0.3.1", "class-transformer": "0.3.1",
"class-validator": "0.12.2", "class-validator": "0.12.2",
"cookie-parser": "^1.4.5",
"date-fns": "2.16.1", "date-fns": "2.16.1",
"nestjs-typeorm-paginate": "2.1.1", "nestjs-typeorm-paginate": "2.1.1",
"passport": "0.4.1", "passport": "0.4.1",
@ -53,6 +54,7 @@
"@nestjs/cli": "7.4.1", "@nestjs/cli": "7.4.1",
"@nestjs/schematics": "7.0.1", "@nestjs/schematics": "7.0.1",
"@nestjs/testing": "7.3.1", "@nestjs/testing": "7.3.1",
"@types/cookie-parser": "^1.4.2",
"@types/express": "4.17.8", "@types/express": "4.17.8",
"@types/hapi__joi": "17.1.4", "@types/hapi__joi": "17.1.4",
"@types/jest": "26.0.13", "@types/jest": "26.0.13",

View file

@ -0,0 +1,26 @@
import {
Column,
CreateDateColumn,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
} from "typeorm";
import { User } from "../users/user.entity";
@Entity()
export class AuthSession {
@PrimaryGeneratedColumn("uuid")
id: string;
@ManyToOne((type) => User, { eager: true })
user: User;
@CreateDateColumn()
createdAt: Date;
@Column({ type: "timestamp", default: () => "CURRENT_TIMESTAMP" })
lastUsedAt: Date;
@Column({ type: "timestamp", nullable: true })
revokedAt: Date | null;
}

View file

@ -0,0 +1,23 @@
// tslint:disable: max-classes-per-file
import { EntityRepository, Repository, SelectQueryBuilder } from "typeorm";
import { User } from "../users/user.entity";
import { AuthSession } from "./auth-session.entity";
export class AuthSessionScopes extends SelectQueryBuilder<AuthSession> {
/**
* `byUser` scopes the query to AuthSessions created by the user.
* @param currentUser
*/
byUser(currentUser: User): this {
return this.andWhere(`session."userId" = :userID`, {
userID: currentUser.id,
});
}
}
@EntityRepository(AuthSession)
export class AuthSessionRepository extends Repository<AuthSession> {
get scoped(): AuthSessionScopes {
return new AuthSessionScopes(this.createQueryBuilder("session"));
}
}

View file

@ -1,10 +1,23 @@
import { Controller, Get, Res, UseFilters, UseGuards } from "@nestjs/common"; import {
Controller,
Get,
Post,
Res,
UseFilters,
UseGuards,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { AuthGuard } from "@nestjs/passport";
import { Response } from "express"; import { Response } from "express";
import { User } from "../users/user.entity"; import { User } from "../users/user.entity";
import { AuthSession } from "./auth-session.entity";
import { AuthService } from "./auth.service"; import { AuthService } from "./auth.service";
import { COOKIE_REFRESH_TOKEN } from "./constants";
import { ReqUser } from "./decorators/req-user.decorator"; import { ReqUser } from "./decorators/req-user.decorator";
import { RefreshAccessTokenResponseDto } from "./dto/refresh-access-token-response.dto";
import {
RefreshTokenAuthGuard,
SpotifyAuthGuard,
} from "./guards/auth-strategies.guard";
import { SpotifyAuthFilter } from "./spotify.filter"; import { SpotifyAuthFilter } from "./spotify.filter";
@Controller("api/v1/auth") @Controller("api/v1/auth")
@ -15,26 +28,33 @@ export class AuthController {
) {} ) {}
@Get("spotify") @Get("spotify")
@UseGuards(AuthGuard("spotify")) @UseGuards(SpotifyAuthGuard)
spotifyRedirect() { spotifyRedirect() {
// User is redirected by AuthGuard // User is redirected by AuthGuard
} }
@Get("spotify/callback") @Get("spotify/callback")
@UseFilters(SpotifyAuthFilter) @UseFilters(SpotifyAuthFilter)
@UseGuards(AuthGuard("spotify")) @UseGuards(SpotifyAuthGuard)
async spotifyCallback(@ReqUser() user: User, @Res() res: Response) { async spotifyCallback(@ReqUser() user: User, @Res() res: Response) {
const { accessToken } = await this.authService.createToken(user); const { refreshToken } = await this.authService.createSession(user);
// Transmit accessToken to Frontend // Refresh token should not be accessible to frontend to reduce risk
res.cookie("listory_access_token", accessToken, { // of XSS attacks.
maxAge: 24 * 60 * 60 * 1000, // 1 day res.cookie(COOKIE_REFRESH_TOKEN, refreshToken, { httpOnly: true });
// Must be readable by SPA
httpOnly: false,
});
// Redirect User to SPA // Redirect User to SPA
res.redirect("/login/success?source=spotify"); res.redirect("/login/success?source=spotify");
} }
@Post("token/refresh")
@UseGuards(RefreshTokenAuthGuard)
async refreshAccessToken(
// With RefreshTokenAuthGuard the session is available instead of user
@ReqUser() session: AuthSession
): Promise<RefreshAccessTokenResponseDto> {
const { accessToken } = await this.authService.createAccessToken(session);
return { accessToken };
}
} }

View file

@ -1,15 +1,20 @@
import { Module } from "@nestjs/common"; import { MiddlewareConsumer, Module, NestModule } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { JwtModule } from "@nestjs/jwt"; import { JwtModule } from "@nestjs/jwt";
import { PassportModule } from "@nestjs/passport"; import { PassportModule } from "@nestjs/passport";
import { TypeOrmModule } from "@nestjs/typeorm";
import { CookieParserMiddleware } from "../cookie-parser";
import { UsersModule } from "../users/users.module"; import { UsersModule } from "../users/users.module";
import { AuthSessionRepository } from "./auth-session.repository";
import { AuthController } from "./auth.controller"; import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service"; import { AuthService } from "./auth.service";
import { JwtStrategy } from "./jwt.strategy"; import { AccessTokenStrategy } from "./strategies/access-token.strategy";
import { SpotifyStrategy } from "./spotify.strategy"; import { RefreshTokenStrategy } from "./strategies/refresh-token.strategy";
import { SpotifyStrategy } from "./strategies/spotify.strategy";
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([AuthSessionRepository]),
PassportModule.register({ defaultStrategy: "jwt" }), PassportModule.register({ defaultStrategy: "jwt" }),
JwtModule.registerAsync({ JwtModule.registerAsync({
useFactory: (config: ConfigService) => ({ useFactory: (config: ConfigService) => ({
@ -23,8 +28,17 @@ import { SpotifyStrategy } from "./spotify.strategy";
}), }),
UsersModule, UsersModule,
], ],
providers: [AuthService, SpotifyStrategy, JwtStrategy], providers: [
AuthService,
SpotifyStrategy,
AccessTokenStrategy,
RefreshTokenStrategy,
],
exports: [PassportModule], exports: [PassportModule],
controllers: [AuthController], controllers: [AuthController],
}) })
export class AuthModule {} export class AuthModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(CookieParserMiddleware).forRoutes("api/v1/auth");
}
}

View file

@ -1,19 +1,27 @@
import { Injectable, ForbiddenException } from "@nestjs/common"; import { ForbiddenException, Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { JwtService } from "@nestjs/jwt"; import { JwtService } from "@nestjs/jwt";
import { User } from "../users/user.entity"; import { User } from "../users/user.entity";
import { UsersService } from "../users/users.service"; import { UsersService } from "../users/users.service";
import { AuthSession } from "./auth-session.entity";
import { AuthSessionRepository } from "./auth-session.repository";
import { LoginDto } from "./dto/login.dto"; import { LoginDto } from "./dto/login.dto";
@Injectable() @Injectable()
export class AuthService { export class AuthService {
private readonly userFilter: null | string; private readonly userFilter: null | string;
private readonly sessionExpirationTime: string;
constructor( constructor(
private readonly config: ConfigService, private readonly config: ConfigService,
private readonly usersService: UsersService, private readonly usersService: UsersService,
private readonly jwtService: JwtService private readonly jwtService: JwtService,
private readonly authSessionRepository: AuthSessionRepository
) { ) {
this.userFilter = this.config.get<string>("SPOTIFY_USER_FILTER"); this.userFilter = this.config.get<string>("SPOTIFY_USER_FILTER");
this.sessionExpirationTime = this.config.get<string>(
"SESSION_EXPIRATION_TIME"
);
} }
async spotifyLogin({ async spotifyLogin({
@ -38,6 +46,67 @@ export class AuthService {
return user; return user;
} }
async createSession(
user: User
): Promise<{
session: AuthSession;
refreshToken: string;
}> {
const session = this.authSessionRepository.create();
session.user = user;
await this.authSessionRepository.save(session);
const [{ refreshToken }] = await Promise.all([
this.createRefreshToken(session),
this.createAccessToken(session),
]);
return { session, refreshToken };
}
/**
* createRefreshToken should only be used while creating a new session.
* @param session
*/
private async createRefreshToken(
session: AuthSession
): Promise<{ refreshToken }> {
const payload = {
sub: session.user.id,
name: session.user.displayName,
};
const token = await this.jwtService.signAsync(payload, {
jwtid: session.id,
// jwtService uses the shorter access token time as a default
expiresIn: this.sessionExpirationTime,
});
return { refreshToken: token };
}
async createAccessToken(session: AuthSession): Promise<{ accessToken }> {
if (session.revokedAt) {
throw new ForbiddenException("SessionIsRevoked");
}
const payload = {
sub: session.user.id,
name: session.user.displayName,
picture: session.user.photo,
};
const token = await this.jwtService.signAsync(payload);
return { accessToken: token };
}
/**
* Switch to createAccessToken
* @deprecated
* @param user
*/
async createToken(user: User): Promise<{ accessToken }> { async createToken(user: User): Promise<{ accessToken }> {
const payload = { const payload = {
sub: user.id, sub: user.id,
@ -50,6 +119,10 @@ export class AuthService {
return { accessToken: token }; return { accessToken: token };
} }
async findSession(id: string): Promise<AuthSession> {
return this.authSessionRepository.findOne(id);
}
async findUser(id: string): Promise<User> { async findUser(id: string): Promise<User> {
return this.usersService.findById(id); return this.usersService.findById(id);
} }

1
src/auth/constants.ts Normal file
View file

@ -0,0 +1 @@
export const COOKIE_REFRESH_TOKEN = "listory_refresh_token";

View file

@ -1,10 +1,10 @@
import { applyDecorators, UseGuards } from "@nestjs/common"; import { applyDecorators, UseGuards } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
import { ApiBearerAuth, ApiUnauthorizedResponse } from "@nestjs/swagger"; import { ApiBearerAuth, ApiUnauthorizedResponse } from "@nestjs/swagger";
import { AccessTokenAuthGuard } from "../guards/auth-strategies.guard";
export function Auth() { export function AuthAccessToken() {
return applyDecorators( return applyDecorators(
UseGuards(AuthGuard("jwt")), UseGuards(AccessTokenAuthGuard),
ApiBearerAuth(), ApiBearerAuth(),
ApiUnauthorizedResponse({ description: 'Unauthorized"' }) ApiUnauthorizedResponse({ description: 'Unauthorized"' })
); );

View file

@ -0,0 +1,3 @@
export class RefreshAccessTokenResponseDto {
accessToken: string;
}

View file

@ -0,0 +1,9 @@
import { AuthGuard } from "@nestjs/passport";
import { AuthStrategy } from "../strategies/strategies.enum";
// Internal
export const AccessTokenAuthGuard = AuthGuard(AuthStrategy.AccessToken);
export const RefreshTokenAuthGuard = AuthGuard(AuthStrategy.RefreshToken);
// Auth Provider
export const SpotifyAuthGuard = AuthGuard(AuthStrategy.Spotify);

View file

@ -1,11 +1,15 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { PassportStrategy } from "@nestjs/passport"; import { PassportStrategy } from "@nestjs/passport";
import { Strategy, ExtractJwt } from "passport-jwt"; import { ExtractJwt, Strategy } from "passport-jwt";
import { AuthService } from "./auth.service"; import { AuthService } from "../auth.service";
import { AuthStrategy } from "./strategies.enum";
@Injectable() @Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) { export class AccessTokenStrategy extends PassportStrategy(
Strategy,
AuthStrategy.AccessToken
) {
constructor( constructor(
private readonly authService: AuthService, private readonly authService: AuthService,
config: ConfigService config: ConfigService
@ -17,7 +21,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
}); });
} }
async validate(payload: any) { async validate(payload: { sub: string }) {
return this.authService.findUser(payload.sub); return this.authService.findUser(payload.sub);
} }
} }

View file

@ -0,0 +1,48 @@
import {
Injectable,
UnauthorizedException,
ForbiddenException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PassportStrategy } from "@nestjs/passport";
import { JwtFromRequestFunction, Strategy } from "passport-jwt";
import { AuthService } from "../auth.service";
import { COOKIE_REFRESH_TOKEN } from "../constants";
import { AuthStrategy } from "./strategies.enum";
import { AuthSession } from "../auth-session.entity";
const extractJwtFromCookie: JwtFromRequestFunction = (req) => {
const token = req.cookies[COOKIE_REFRESH_TOKEN] || null;
return token;
};
@Injectable()
export class RefreshTokenStrategy extends PassportStrategy(
Strategy,
AuthStrategy.RefreshToken
) {
constructor(
private readonly authService: AuthService,
config: ConfigService
) {
super({
jwtFromRequest: extractJwtFromCookie,
ignoreExpiration: false,
secretOrKey: config.get<string>("JWT_SECRET"),
});
}
async validate(payload: { jti: string }): Promise<AuthSession> {
const session = await this.authService.findSession(payload.jti);
if (!session) {
throw new UnauthorizedException("SessionNotFound");
}
if (session.revokedAt) {
throw new ForbiddenException("SessionIsRevoked");
}
return session;
}
}

View file

@ -2,10 +2,14 @@ import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { PassportStrategy } from "@nestjs/passport"; import { PassportStrategy } from "@nestjs/passport";
import { Strategy } from "passport-spotify"; import { Strategy } from "passport-spotify";
import { AuthService } from "./auth.service"; import { AuthService } from "../auth.service";
import { AuthStrategy } from "./strategies.enum";
@Injectable() @Injectable()
export class SpotifyStrategy extends PassportStrategy(Strategy) { export class SpotifyStrategy extends PassportStrategy(
Strategy,
AuthStrategy.Spotify
) {
constructor( constructor(
private readonly authService: AuthService, private readonly authService: AuthService,
config: ConfigService config: ConfigService

View file

@ -0,0 +1,8 @@
export enum AuthStrategy {
// Internal
AccessToken = "access_token",
RefreshToken = "refresh_token",
// Auth Provider
Spotify = "spotify",
}

View file

@ -1,9 +1,6 @@
import * as Joi from "@hapi/joi"; import * as Joi from "@hapi/joi";
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { import { ConfigModule as NestConfigModule } from "@nestjs/config";
ConfigModule as NestConfigModule,
ConfigService,
} from "@nestjs/config";
@Module({ @Module({
imports: [ imports: [
@ -19,7 +16,9 @@ import {
JWT_ALGORITHM: Joi.string() JWT_ALGORITHM: Joi.string()
.default("HS256") .default("HS256")
.allow("HS256", "HS384", "HS512"), .allow("HS256", "HS384", "HS512"),
JWT_EXPIRATION_TIME: Joi.string().default("1d"),
JWT_EXPIRATION_TIME: Joi.string().default("15m"),
SESSION_EXPIRATION_TIME: Joi.string().default("1y"),
// Spotify // Spotify
SPOTIFY_CLIENT_ID: Joi.string().required(), SPOTIFY_CLIENT_ID: Joi.string().required(),

View file

@ -0,0 +1,16 @@
import { Injectable, NestMiddleware } from "@nestjs/common";
import * as cookieParser from "cookie-parser";
import type { RequestHandler } from "express";
@Injectable()
export class CookieParserMiddleware implements NestMiddleware {
private readonly middleware: RequestHandler;
constructor() {
this.middleware = cookieParser();
}
use(req: any, res: any, next: () => void) {
return this.middleware(req, res, next);
}
}

View file

@ -0,0 +1 @@
export { CookieParserMiddleware } from "./cookie-parser.middleware";

View file

@ -0,0 +1,69 @@
import {
MigrationInterface,
QueryRunner,
Table,
TableIndex,
TableForeignKey,
} from "typeorm";
import { TableColumnOptions } from "typeorm/schema-builder/options/TableColumnOptions";
const primaryUUIDColumn: TableColumnOptions = {
name: "id",
type: "uuid",
isPrimary: true,
isGenerated: true,
generationStrategy: "uuid",
};
export class CreateAuthSessionsTable0000000000004
implements MigrationInterface {
async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: "auth_session",
columns: [
primaryUUIDColumn,
{
name: "userId",
type: "uuid",
},
{
name: "createdAt",
type: "timestamp",
default: "CURRENT_TIMESTAMP",
},
{
name: "lastUsedAt",
type: "timestamp",
default: "CURRENT_TIMESTAMP",
},
{
name: "revokedAt",
type: "timestamp",
default: null,
isNullable: true,
},
],
indices: [
new TableIndex({
name: "IDX_AUTH_SESSION_USER_ID",
columnNames: ["userId"],
}),
],
foreignKeys: [
new TableForeignKey({
name: "FK_AUTH_SESSION_USER_ID",
columnNames: ["userId"],
referencedColumnNames: ["id"],
referencedTableName: "user",
}),
],
}),
true
);
}
async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable("auth_session");
}
}

View file

@ -1,6 +1,6 @@
import { Controller, Get, Query } from "@nestjs/common"; import { Controller, Get, Query } from "@nestjs/common";
import { Pagination } from "nestjs-typeorm-paginate"; import { Pagination } from "nestjs-typeorm-paginate";
import { Auth } from "../auth/decorators/auth.decorator"; import { AuthAccessToken } from "../auth/decorators/auth-access-token.decorator";
import { ReqUser } from "../auth/decorators/req-user.decorator"; import { ReqUser } from "../auth/decorators/req-user.decorator";
import { User } from "../users/user.entity"; import { User } from "../users/user.entity";
import { GetListensFilterDto } from "./dto/get-listens.dto"; import { GetListensFilterDto } from "./dto/get-listens.dto";
@ -12,7 +12,7 @@ export class ListensController {
constructor(private readonly listensService: ListensService) {} constructor(private readonly listensService: ListensService) {}
@Get() @Get()
@Auth() @AuthAccessToken()
async getRecentlyPlayed( async getRecentlyPlayed(
@Query("page") page: number = 1, @Query("page") page: number = 1,
@Query("limit") limit: number = 10, @Query("limit") limit: number = 10,

View file

@ -1,5 +1,5 @@
import { Controller, Get, Query } from "@nestjs/common"; import { Controller, Get, Query } from "@nestjs/common";
import { Auth } from "../auth/decorators/auth.decorator"; import { AuthAccessToken } from "../auth/decorators/auth-access-token.decorator";
import { ReqUser } from "../auth/decorators/req-user.decorator"; import { ReqUser } from "../auth/decorators/req-user.decorator";
import { User } from "../users/user.entity"; import { User } from "../users/user.entity";
import { ListenReportDto } from "./dto/listen-report.dto"; import { ListenReportDto } from "./dto/listen-report.dto";
@ -13,7 +13,7 @@ export class ReportsController {
constructor(private readonly reportsService: ReportsService) {} constructor(private readonly reportsService: ReportsService) {}
@Get("listens") @Get("listens")
@Auth() @AuthAccessToken()
async getListens( async getListens(
@Query() time: ReportTimeDto, @Query() time: ReportTimeDto,
@Query("timeFrame") timeFrame: Timeframe, @Query("timeFrame") timeFrame: Timeframe,
@ -23,7 +23,7 @@ export class ReportsController {
} }
@Get("top-artists") @Get("top-artists")
@Auth() @AuthAccessToken()
async getTopArtists( async getTopArtists(
@Query() time: ReportTimeDto, @Query() time: ReportTimeDto,
@ReqUser() user: User @ReqUser() user: User

View file

@ -1,12 +1,12 @@
import { Controller, Get } from "@nestjs/common"; import { Controller, Get } from "@nestjs/common";
import { Auth } from "../auth/decorators/auth.decorator"; import { AuthAccessToken } from "../auth/decorators/auth-access-token.decorator";
import { ReqUser } from "../auth/decorators/req-user.decorator"; import { ReqUser } from "../auth/decorators/req-user.decorator";
import { User } from "./user.entity"; import { User } from "./user.entity";
@Controller("api/v1/users") @Controller("api/v1/users")
export class UsersController { export class UsersController {
@Get("me") @Get("me")
@Auth() @AuthAccessToken()
getMe(@ReqUser() user: User): Omit<User, "spotify"> { getMe(@ReqUser() user: User): Omit<User, "spotify"> {
return { return {
id: user.id, id: user.id,