feat: refactor directus and add response validation

gratitude
Katja Lutz 2 years ago
parent cb93157285
commit 00d540a627

27
package-lock.json generated

@ -8,8 +8,10 @@
"dependencies": {
"@urql/exchange-auth": "^0.1.7",
"graphql": "^16.4.0",
"myzod": "^1.8.7",
"turbo-solid": "^1.0.0",
"urql": "^2.2.0"
"urql": "^2.2.0",
"zod": "^3.16.0"
},
"devDependencies": {
"autoprefixer": "^10.4.2",
@ -3486,6 +3488,11 @@
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
"dev": true
},
"node_modules/myzod": {
"version": "1.8.7",
"resolved": "https://registry.npmjs.org/myzod/-/myzod-1.8.7.tgz",
"integrity": "sha512-H/Nmst+ZIGQppKVeOq6ufieRnnK0u+UfDLgCrG1Rtn6W/GzMoz6Ur9/iLBwB0N8s6ZE/4hhbRTUt3Pg7nH4X2Q=="
},
"node_modules/nanoid": {
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz",
@ -4589,6 +4596,14 @@
"engines": {
"node": ">= 6"
}
},
"node_modules/zod": {
"version": "3.16.0",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.16.0.tgz",
"integrity": "sha512-szrIkryADbTM+xBt2a1KoS2CJQXec4f9xG78bj5MJeEH/XqmmHpnO+fG3IE115AKBJak+2HrbxLZkc9mhdbDKA==",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
},
"dependencies": {
@ -7015,6 +7030,11 @@
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
"dev": true
},
"myzod": {
"version": "1.8.7",
"resolved": "https://registry.npmjs.org/myzod/-/myzod-1.8.7.tgz",
"integrity": "sha512-H/Nmst+ZIGQppKVeOq6ufieRnnK0u+UfDLgCrG1Rtn6W/GzMoz6Ur9/iLBwB0N8s6ZE/4hhbRTUt3Pg7nH4X2Q=="
},
"nanoid": {
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz",
@ -7772,6 +7792,11 @@
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
"dev": true
},
"zod": {
"version": "3.16.0",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.16.0.tgz",
"integrity": "sha512-szrIkryADbTM+xBt2a1KoS2CJQXec4f9xG78bj5MJeEH/XqmmHpnO+fG3IE115AKBJak+2HrbxLZkc9mhdbDKA=="
}
}
}

@ -28,7 +28,9 @@
"dependencies": {
"@urql/exchange-auth": "^0.1.7",
"graphql": "^16.4.0",
"myzod": "^1.8.7",
"turbo-solid": "^1.0.0",
"urql": "^2.2.0"
"urql": "^2.2.0",
"zod": "^3.16.0"
}
}

@ -1,77 +0,0 @@
import { useLocation } from "solid-app-router";
import {
createComputed,
createContext,
createEffect,
createResource,
useContext,
} from "solid-js";
import { createStore } from "solid-js/store";
import server, { redirect } from "solid-start/server";
import { useRefreshy } from "~/util/refreshy";
import { awaitJson, withRC } from "~/util";
import { getSessionData } from "./server";
export const authData = (
options_: { redirectNoAuth?: string; redirectWithAuth?: string } = {}
) =>
function () {
const [refreshy] = useRefreshy();
const fetcher = refreshy(
server(
withRC(async (rc, options: typeof options_) => {
const data = await getSessionData(rc);
if (options.redirectNoAuth && !data?.username) {
throw redirect(options.redirectNoAuth);
}
if (options.redirectWithAuth && data?.username) {
throw redirect(options.redirectWithAuth);
}
return { username: data.username };
})
)
);
return createResource(() => fetcher(options_), {
name: "auth",
deferStream: true,
});
};
/*
const AuthContext = createContext();
export const AuthProvider = (props) => {
const [session, { refetch }] = createResource(server(() => getSessionData()))
const [state, setState] = createStore([{
session: {}
}])
const location = useLocation();
createEffect(function() {
const url = location.pathname;
refetch()
})
createEffect(function() {
setState(0,'session',session())
})
//createComputed(session)
return <AuthContext.Provider value={state}>
{props.children}
</AuthContext.Provider>
}
export const useAuth = () => {
const authContext = useContext(AuthContext)
console.log(authContext)
return authContext[0];
}
*/

@ -4,7 +4,7 @@ import {
renderAsync,
renderStream,
} from "solid-start/entry-server";
import { refreshAuthToken } from "./auth/server";
import { refreshAuthToken } from "./server/auth";
import { createRefreshMiddleware } from "./util/refreshy";
import { createRC } from "./util";

@ -4,7 +4,7 @@ import { Links, Meta, Routes, Scripts } from "solid-start/root";
import { ErrorBoundary } from "solid-start/error-boundary";
import "./index.css";
import { RefreshFunction, RefreshyProvider } from "./util/refreshy";
import { serveRefreshAuthToken } from "./auth/server";
import { serveRefreshAuthToken } from "./server/auth";
const refresh: RefreshFunction = async () => {
console.log("refresh jwt");

@ -1,8 +1,7 @@
import { Link, useRouteData } from "solid-app-router";
import { createEffect, createResource, For, Show } from "solid-js";
import server from "solid-start/server";
import { login, serveLogout } from "~/auth/server";
import { authData } from "~/auth/client";
import { login, serveLogout, authData } from "~/server/auth";
import { composeRouteData, useServer, withRC } from "~/util";
import { serveTodos } from "~/server/todos";
import { useRefreshy } from "~/util/refreshy";

@ -1,6 +1,6 @@
import { useRouteData } from "solid-app-router";
import { createResource } from "solid-js";
import { authData } from "~/auth/client";
import { authData } from "~/server/auth";
import { serveJwtData } from "~/server/jwtData";
import { composeRouteData } from "~/util";
import { useRefreshy } from "~/util/refreshy";

@ -1,7 +1,6 @@
import { login } from "~/auth/server";
import server from "solid-start/server";
import { composeRouteData, useServer, withRC } from "~/util";
import { authData } from "~/auth/client";
import { authData, login } from "~/server/auth";
import { useNavigate } from "solid-app-router";
export const routeData = composeRouteData({

@ -1,24 +1,23 @@
import { useRouteData } from "solid-app-router";
import { createResource } from "solid-js";
import server from "solid-start/server";
import { login } from "~/auth/server";
import { login } from "~/server/auth";
import { withRC } from "~/util";
// TODO: This doesn't work with renderStream!
// Open up: http://localhost:3000/loginWithGet?username=admin@example.com&password=d1r3ctu5
export function routeData() {
return createResource(
server(
withRC((rc) => {
const username = rc.url.searchParams.get("username");
const password = rc.url.searchParams.get("password");
const loginWithGet = server(
withRC((rc) => {
const username = rc.url.searchParams.get("username") || "";
const password = rc.url.searchParams.get("password") || "";
return login(rc, username, password, "/");
})
),
{ deferStream: true }
return login(rc, username, password, "/");
})
);
return createResource(loginWithGet, { deferStream: true });
}
export default () => {

@ -1,11 +1,11 @@
import { useRouteData } from "solid-app-router";
import { createResource } from "solid-js";
import server from "solid-start/server";
import { logout } from "~/auth/server";
import { serveLogout } from "~/server/auth";
import { withRC } from "~/util";
export function routeData() {
return createResource(server(withRC((rc) => logout(rc))));
return createResource(() => serveLogout());
}
// http://localhost:3000/login?username=admin@example.com&password=d1r3ctu5

@ -1,9 +1,10 @@
import { createCookieSessionStorage } from "solid-start/session";
import server, { json, redirect } from "solid-start/server";
import { gql, OperationResult } from "urql";
import { Session } from "solid-start/session/sessions";
import { awaitJson, RC, withRC } from "~/util";
import { createDirectusSystemClient } from "~/server/directus";
import * as directus from "~/server/directus";
import { useRefreshy } from "~/util/refreshy";
import { createResource } from "solid-js";
const storage = createCookieSessionStorage({
cookie: {
@ -21,42 +22,22 @@ const storage = createCookieSessionStorage({
export const login = async (
rc: RC,
username,
password,
username: string,
password: string,
redirectTo = "/" as string | false
) => {
const client = createDirectusSystemClient();
let result: OperationResult;
try {
result = await client
.mutation(
gql`
mutation {
auth_login(email: "${username}", password: "${password}") {
access_token
refresh_token
expires
}
}
`
)
.toPromise();
const session = await storage.getSession();
if (result.error) {
console.dir(result.error);
throw new Error(result.error.response);
if (isLoggedIn(session)) {
if (redirectTo) {
throw redirect(redirectTo);
}
} catch (err) {
console.error(err);
return;
}
if (!result) return;
const session = await storage.getSession();
const loginData = await directus.login({ username, password });
session.set("username", username);
setAuthInSession(session, result.data.auth_login);
setAuthInSession(session, loginData);
const newCookie = await storage.commitSession(session);
const response = {
@ -75,7 +56,7 @@ export const isLoggedIn = (session: Session) => {
const setAuthInSession = (
session: Session,
{ access_token, refresh_token, expires }
{ access_token, refresh_token, expires }: directus.AuthSchema
) => {
session.set("accessToken", access_token);
session.set("refreshToken", refresh_token);
@ -101,57 +82,34 @@ export const refreshAuthToken = async (rc: RC) => {
// console.log(tokenTime, Number.isInteger(tokenTime))
// console.log(tokenExpires, Number.isInteger(tokenExpires))
if (new Date().getTime() < tokenTime + tokenExpires / 2) {
if (false && new Date().getTime() < tokenTime + tokenExpires / 2) {
console.log("not expired");
return;
}
console.log("token gets refreshed", refreshToken);
let result: OperationResult;
const client = createDirectusSystemClient();
try {
result = await client
.mutation(
gql`
mutation {
auth_refresh(refresh_token: "${refreshToken}") {
access_token
refresh_token
expires
const refreshData = await directus.refresh({ refreshToken });
setAuthInSession(session, refreshData);
const newCookie = await storage.commitSession(session);
rc.responseHeaders.set("Set-Cookie", newCookie);
return json(
{},
{
headers: {
"Set-Cookie": newCookie,
},
}
}
`
)
.toPromise();
if (result.error) {
console.log("code", result.error.graphQLErrors[0].extensions.code);
console.log(result.error.graphQLErrors[0].extensions.graphqlErrors);
console.dir(result.error);
throw new Error(result.error.response);
}
);
} catch (err) {
console.error("Refresh has failed");
console.error(err);
}
if (!result?.data?.auth_refresh) return;
setAuthInSession(session, result.data.auth_refresh);
const newCookie = await storage.commitSession(session);
rc.responseHeaders.set("Set-Cookie", newCookie);
return json(
{},
{
headers: {
"Set-Cookie": newCookie,
},
}
);
return false;
};
export const serveRefreshAuthToken = server(
@ -180,6 +138,15 @@ export const logout = async function (
redirectTo = "/" as string | false
) {
const session = await getSession(rc);
if (!isLoggedIn(session)) {
if (redirectTo) {
throw redirect(redirectTo);
}
return;
}
const refreshToken = session.get("refreshToken");
const logoutData = await directus.logout({ refreshToken });
const newCookie = await storage.destroySession(session);
const response = {
@ -192,3 +159,33 @@ export const logout = async function (
throw redirectTo ? redirect(redirectTo, response) : json({}, response);
};
export const serveLogout = server(withRC((rc) => logout(rc, false)));
export const authData = (
options_: { redirectNoAuth?: string; redirectWithAuth?: string } = {}
) =>
function () {
const [refreshy] = useRefreshy();
const fetcher = refreshy(
server(
withRC(async (rc, options: typeof options_) => {
const data = await getSessionData(rc);
if (options.redirectNoAuth && !data?.username) {
throw redirect(options.redirectNoAuth);
}
if (options.redirectWithAuth && data?.username) {
throw redirect(options.redirectWithAuth);
}
return { username: data.username };
})
)
);
return createResource(() => fetcher(options_), {
name: "auth",
deferStream: true,
});
};

@ -4,9 +4,12 @@ import {
fetchExchange,
errorExchange,
makeOperation,
Client,
gql,
} from "urql";
import { authExchange } from "@urql/exchange-auth";
import { GraphQLError } from "graphql";
import z, { Infer } from "myzod";
export const createDirectusSystemClient = () => {
const client = createClient({
@ -16,7 +19,103 @@ export const createDirectusSystemClient = () => {
return client;
};
export const createDirectusClient = (session: Session, onAuthError?) => {
export const authSchema = z.object(
{
access_token: z.string(),
refresh_token: z.string(),
expires: z.number(),
},
{ allowUnknown: true }
);
export type AuthSchema = Infer<typeof authSchema>;
export const login = async (options: {
username: string;
password: string;
client?: Client;
}) => {
const client = options.client || createDirectusSystemClient();
const result = await client
.mutation(
gql`
mutation {
auth_login(email: "${options.username}", password: "${options.password}") {
access_token
refresh_token
expires
}
}`
)
.toPromise();
if (result.error) {
throw result.error;
}
return authSchema.parse(result.data.auth_login);
};
export const refresh = async (options: {
refreshToken: string;
client?: Client;
}) => {
const client = options.client || createDirectusSystemClient();
const result = await client
.mutation(
gql`
mutation {
auth_refresh(refresh_token: "${options.refreshToken}") {
access_token
refresh_token
expires
}
}`
)
.toPromise();
if (result.error) {
throw new Error(result.error.toString());
}
/*
console.log("code", result.error.graphQLErrors[0].extensions.code);
console.log(result.error.graphQLErrors[0].extensions.graphqlErrors);
console.dir(result.error);
throw new Error(result.error.response);
*/
return authSchema.parse(result.data.auth_refresh);
};
export const logout = async (options: {
refreshToken: string;
client?: Client;
}) => {
const client = options.client || createDirectusSystemClient();
const result = await client
.mutation(
gql`
mutation {
auth_logout(refresh_token: "${options.refreshToken}")
}`
)
.toPromise();
if (result.error) {
throw result.error;
}
return z.boolean().parse(result.data.auth_logout);
};
export const createDirectusClient = (
session: Session,
onAuthError?: Function
) => {
const client = createClient({
url: "http://localhost:8055/graphql",
exchanges: [

@ -1,5 +1,5 @@
import { gql, OperationResult } from "urql";
import { getSession, isLoggedIn, logout } from "../auth/server";
import { getSession, isLoggedIn, logout } from "./auth";
import server from "solid-start/server";
import { awaitJson, RC, withRC } from "../util";
import { createDirectusClient } from "./directus";

@ -1,3 +1,4 @@
import { Result } from "postcss";
import { useNavigate } from "solid-app-router";
import {
createEffect,
@ -10,34 +11,41 @@ import { createStore, produce } from "solid-js/store";
import { isRedirectResponse, LocationHeader } from "solid-start/server";
// Should handle redirects, but it broke "quickly"
export const useServer = function (serverFunction, callback?) {
export const useServer = function (
serverFunction: Function,
callback?: Function
) {
const navigate = useNavigate();
return async () => {
let res;
try {
res = await serverFunction();
const res = await serverFunction();
return callback ? callback(res) : res;
} catch (err) {
if (err instanceof Response) {
res = err;
if (!(err instanceof Response)) {
throw err;
}
if (!isRedirectResponse(err)) {
return callback ? callback(err) : err;
}
const newLocation = err.headers.get(LocationHeader);
if (!newLocation) {
throw new Error("Got RedirectResponse without location");
}
}
if (isRedirectResponse(res)) {
startTransition(() => {
navigate(res.headers.get(LocationHeader));
startTransition(function () {
navigate(newLocation);
resetErrorBoundaries();
});
return null;
}
return callback ? callback(res) : res;
};
};
export const awaitJson = <T extends (...args) => any>(fn: T) => {
export const awaitJson = <T extends (...args: any[]) => any>(fn: T) => {
return async (...args: Parameters<T>) => {
const result = await fn(...args);
@ -65,10 +73,10 @@ export const awaitJson = <T extends (...args) => any>(fn: T) => {
};
export const composeRouteData =
(functionMap: Record<any, (state) => ResourceReturn<any>>) => () => {
const [state, setState] = createStore({} as any);
(functionMap: Record<any, (state: any) => ResourceReturn<any>>) => () => {
const [state, setState] = createStore<Record<string, any>>({});
const repos = {};
const repos = {} as Record<string, ResourceReturn<any>>;
for (const dataKey of Object.keys(functionMap)) {
repos[dataKey] = functionMap[dataKey](state);
@ -131,7 +139,18 @@ export const withRC = function <
R extends any[],
T extends (rc: RC, ...args: R) => any
>(fn: T) {
return function (...args: R): ReturnType<T> {
return fn(createRC(this.request, this.responseHeaders), ...args);
return async function (
this: any,
...args: R
): Promise<ReturnType<Awaited<T>>> {
try {
return await fn(createRC(this.request, this.responseHeaders), ...args);
} catch (err) {
if (!(err instanceof Response)) {
console.log(err);
}
throw err;
}
};
};

@ -5,6 +5,7 @@ import {
useContext,
} from "solid-js";
import { isServer } from "solid-js/web";
import { Middleware, MiddlewareFn } from "solid-start/entry-server";
const refresh_ = async function (
ctx: ReturnType<typeof createRefreshyContext>
@ -34,7 +35,7 @@ const createRefreshyContext = function (
refreshSoon = false
) {
const refreshResult = createSignal();
const refreshy = <T extends (...args) => Promise<any>>(fetcher: T) => {
const refreshy = <T extends (...args: any[]) => Promise<any>>(fetcher: T) => {
if (isServer) {
return fetcher;
}
@ -92,7 +93,7 @@ export const createRefreshMiddleware = (
return await forward(ctx);
};
};
} as Middleware;
};
export const useRefreshy = function () {

@ -1,5 +1,6 @@
{
"compilerOptions": {
"strict": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"target": "ESNext",

Loading…
Cancel
Save