pPlantation
Authentication plugin for Fresh(Deno) using Lucia.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,47 @@ | ||
name: Test | ||
|
||
on: | ||
push: | ||
branches: ["main", "test"] | ||
pull_request: | ||
branches: ["main", "test"] | ||
schedule: | ||
# Run every Monday at 00:00 UTC | ||
- cron: '0 0 * * 1' | ||
|
||
jobs: | ||
test: | ||
permissions: | ||
contents: read | ||
runs-on: ubuntu-latest | ||
steps: | ||
- name: Setup repo | ||
uses: actions/checkout@v3 | ||
|
||
- name: Setup Deno | ||
uses: denoland/setup-deno@v1 | ||
|
||
- name: Check version | ||
run: deno -V | ||
|
||
- name: Verify formatting | ||
run: deno fmt --check | ||
|
||
- name: Install Fresh | ||
run: deno run -A https://deno.land/x/fresh/init.ts ./tests/work --force --twind --tailwind --vscode | ||
|
||
- name: Move deno.json | ||
run: mv ./tests/work/deno.json ./deno.json | ||
|
||
- name: view deno.json | ||
run: cat ./deno.json | ||
|
||
- name: Run tests | ||
run: deno test --unstable --allow-read --allow-write --allow-env --allow-net --no-check --coverage=./coverage | ||
|
||
- name: View coverage | ||
run: | | ||
# reference: https://github.com/jhechtf/code-coverage | ||
deno install --allow-read --no-check -n code-coverage https://deno.land/x/code_coverage/cli.ts && | ||
deno coverage --exclude=tests/work/ --lcov --output=lcov.info ./coverage/ && | ||
code-coverage --file lcov.info |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
deno.json | ||
tests/work/ | ||
node_modules/ |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
import { parseArgs } from "https://deno.land/[email protected]/cli/parse_args.ts"; | ||
import { ensureDirSync } from "https://deno.land/[email protected]/fs/ensure_dir.ts"; | ||
|
||
const parsedArgs = parseArgs(Deno.args); | ||
|
||
ensureDirSync(`./plantation`); | ||
ensureDirSync(`./plantation/${parsedArgs["_"][0]}`); | ||
|
||
Deno.writeTextFileSync(`./plantation/${parsedArgs["_"][0]}/create.tsx`, "AAA") | ||
Deno.writeTextFileSync(`./plantation/${parsedArgs["_"][0]}/login.tsx`, "AAA") | ||
Deno.writeTextFileSync(`./plantation/${parsedArgs["_"][0]}/logout.tsx`, "AAA") |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
import { JSX } from "preact"; | ||
|
||
interface LogoutFormProps extends JSX.HTMLAttributes<HTMLButtonElement> { | ||
csrfToken: string; | ||
actionPath: string; | ||
} | ||
|
||
export function LogoutForm( | ||
{ csrfToken, actionPath, ...other }: LogoutFormProps, | ||
) { | ||
return ( | ||
<form action={actionPath} method="post"> | ||
<input type="hidden" name="csrf" value={csrfToken} /> | ||
<button | ||
type="submit" | ||
{...other} | ||
/> | ||
</form> | ||
); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
export type { | ||
AppProps, | ||
FreshContext, | ||
Handler, | ||
HandlerContext, | ||
Handlers, | ||
MiddlewareHandlerContext, | ||
PageProps, | ||
Plugin, | ||
PluginRoute, | ||
} from "https://deno.land/x/[email protected]/server.ts"; | ||
export type { ComponentType } from "https://esm.sh/[email protected]"; | ||
export type { Auth, Session } from "npm:lucia"; | ||
export { LuciaError } from "npm:lucia"; | ||
export { z, type ZodIssue } from "https://deno.land/x/[email protected]/mod.ts"; | ||
export { fromFileUrl } from "https://deno.land/[email protected]/path/mod.ts"; | ||
export { existsSync } from "https://deno.land/[email protected]/fs/exists.ts"; | ||
export { pascalCase } from "https://deno.land/x/case/mod.ts"; | ||
export { type WithCsrf, type CsrfOption, getCsrfPlugin} from "https://deno.land/x/[email protected]/mod.ts"; | ||
export { type JSX, h } from "https://esm.sh/[email protected]"; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
import type { FreshContext } from "../deps.ts"; | ||
import type { PlantationInnerParams } from "../types.ts"; | ||
|
||
export function getPlantationMiddleware({ | ||
auth, | ||
paths, | ||
resourceName, | ||
loginAfterPath, | ||
isAllowNoSessionPath, | ||
isSessionLogicPath, | ||
}: PlantationInnerParams) { | ||
return async function (req: Request, ctx: FreshContext) { | ||
if (ctx.destination !== "route") { | ||
return await ctx.next(); | ||
} | ||
|
||
const pathname = new URL(req.url).pathname; | ||
const authRequest = auth.handleRequest(req); | ||
const session = await authRequest.validate(); | ||
|
||
if (isSessionLogicPath(pathname)) { | ||
if (session) { | ||
return new Response("Authorized", { | ||
status: 302, | ||
headers: { Location: loginAfterPath }, | ||
}); | ||
} else { | ||
return await ctx.next(); | ||
} | ||
} else { | ||
if (!isAllowNoSessionPath(pathname) && !session) { | ||
return new Response("Unauthorized", { | ||
status: 302, | ||
headers: { Location: paths.loginPath }, | ||
}); | ||
} | ||
} | ||
|
||
if (session) { | ||
ctx.state[`auth${resourceName}Session`] = session; | ||
} | ||
return await ctx.next(); | ||
}; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
export { | ||
getPlantationPlugin, | ||
getPlantationWithCsrfPlugins, | ||
} from "./plugins/plugin.tsx"; | ||
export { | ||
emailSchema, | ||
passwordSchema, | ||
usernameSchema, | ||
} from "./utils/validates.ts"; | ||
export type { PlantationParams } from "./types.ts"; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,62 @@ | ||
import { type Plugin, type CsrfOption, getCsrfPlugin } from "../deps.ts"; | ||
import { DefaultActions, PlantationParams } from "../types.ts"; | ||
import { getCreateComponent, getCreateHandler } from "../routes/create.tsx"; | ||
import { getLoginComponent, getLoginHandler } from "../routes/login.tsx"; | ||
import { getLogoutHandler } from "../routes/logout.tsx"; | ||
|
||
import { getPlantationMiddleware } from "../middlewares/middleware.ts"; | ||
import { getInnerParams } from "../utils/params.ts"; | ||
import { | ||
getCreateRoute, | ||
getLoginRoute, | ||
getLogoutRoute, | ||
} from "../utils/route.ts"; | ||
|
||
const defaultActions: DefaultActions = { | ||
create: { | ||
getHandler: getCreateHandler, | ||
getComponent: getCreateComponent, | ||
}, | ||
login: { | ||
getHandler: getLoginHandler, | ||
getComponent: getLoginComponent, | ||
}, | ||
logout: { | ||
getHandler: getLogoutHandler, | ||
}, | ||
}; | ||
|
||
export async function getPlantationPlugin( | ||
plantationParams: PlantationParams, | ||
): Promise<Plugin> { | ||
const plantationInnerParams = getInnerParams(plantationParams); | ||
|
||
return { | ||
name: "plantation", | ||
middlewares: [ | ||
{ | ||
middleware: { | ||
handler: getPlantationMiddleware(plantationInnerParams), | ||
}, | ||
path: plantationParams.setupRootPath, | ||
}, | ||
], | ||
routes: [ | ||
await getCreateRoute(plantationInnerParams, defaultActions), | ||
await getLoginRoute(plantationInnerParams, defaultActions), | ||
await getLogoutRoute(plantationInnerParams, defaultActions), | ||
], | ||
}; | ||
} | ||
|
||
export async function getPlantationWithCsrfPlugins( | ||
{ csrf, plantationParams }: { | ||
csrf: { kv: Deno.Kv; csrfOption?: Partial<CsrfOption> | undefined }; | ||
plantationParams: PlantationParams; | ||
}, | ||
) { | ||
return [ | ||
await getCsrfPlugin(csrf.kv, csrf.csrfOption), | ||
await getPlantationPlugin(plantationParams), | ||
]; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,148 @@ | ||
/** @jsx h */ | ||
import { h, FreshContext, LuciaError, PageProps, pascalCase, WithCsrf } from "../deps.ts"; | ||
import { PlantationInnerParams } from "../types.ts"; | ||
import { styles } from "../utils/style.ts"; | ||
import { sameLogicValidate } from "../utils/validates.ts"; | ||
import { PASSWORD } from "../utils/const.ts"; | ||
|
||
export function getCreateHandler( | ||
{ | ||
auth, | ||
loginAfterPath, | ||
resourceIdentifierName, | ||
identifierSchema, | ||
passwordSchema, | ||
}: PlantationInnerParams, | ||
) { | ||
return { | ||
async POST(req: Request, ctx: FreshContext<WithCsrf>) { | ||
const formData = await req.formData(); | ||
const token = formData.get("csrf"); | ||
|
||
if (!ctx.state.csrf.csrfVerifyFunction(token?.toString() ?? null)) { | ||
return new Response(null, { | ||
status: 302, | ||
headers: { | ||
Location: req.headers.get("referer") || "/", | ||
}, | ||
}); | ||
} | ||
|
||
const identifier = formData.get(resourceIdentifierName); | ||
const password = formData.get(PASSWORD); | ||
|
||
const identifierResult = sameLogicValidate( | ||
resourceIdentifierName, | ||
identifier?.toString(), | ||
identifierSchema, | ||
); | ||
const passwordResult = sameLogicValidate( | ||
"password", | ||
password?.toString(), | ||
passwordSchema, | ||
); | ||
|
||
if (!(identifierResult.success && passwordResult.success)) { | ||
return ctx.render({ | ||
errors: [...identifierResult.errors, ...passwordResult.errors], | ||
identifier, | ||
}); | ||
} | ||
|
||
try { | ||
const user = await auth.createUser({ | ||
key: { | ||
providerId: resourceIdentifierName, | ||
providerUserId: identifierResult.data, | ||
password: passwordResult.data, | ||
}, | ||
attributes: { | ||
[resourceIdentifierName]: identifierResult.data, | ||
}, | ||
}); | ||
const session = await auth.createSession({ | ||
userId: user.userId, | ||
attributes: {}, | ||
}); | ||
const sessionCookie = auth.createSessionCookie(session); | ||
|
||
return new Response(null, { | ||
headers: { | ||
Location: loginAfterPath, | ||
"Set-Cookie": sessionCookie.serialize(), // store session cookie | ||
}, | ||
status: 302, | ||
}); | ||
} catch (e) { | ||
console.error("e", e); | ||
if (e instanceof LuciaError) { | ||
return ctx.render({ | ||
errors: ["Auth system error"], | ||
identifier, | ||
}); | ||
} | ||
return new Response("An unknown error occurred", { | ||
status: 500, | ||
}); | ||
} | ||
}, | ||
}; | ||
} | ||
|
||
export function getCreateComponent( | ||
{ resourceIdentifierName, paths }: PlantationInnerParams, | ||
) { | ||
return function ( | ||
{ data, state }: PageProps< | ||
{ errors: string[]; identifier: string }, | ||
WithCsrf | ||
>, | ||
) { | ||
return ( | ||
<div style={styles.block}> | ||
<div> | ||
<h2>Create Account</h2> | ||
</div> | ||
<div> | ||
<form action={paths.createPath} method="post"> | ||
<input type="hidden" name="csrf" value={state.csrf.getTokenStr()} /> | ||
<div style={styles.row}> | ||
{data?.errors?.length > 0 && ( | ||
<ul> | ||
{data.errors.map((error) => <li>{error}</li>)} | ||
</ul> | ||
)} | ||
</div> | ||
<div style={styles.row}> | ||
<label for={resourceIdentifierName} style={styles.label}> | ||
{pascalCase(resourceIdentifierName)} | ||
</label> | ||
<input | ||
type="text" | ||
id={resourceIdentifierName} | ||
name={resourceIdentifierName} | ||
style={styles.textbox} | ||
value={data?.identifier} | ||
/> | ||
</div> | ||
<div style={styles.row}> | ||
<label for="password" style={styles.label}>Password</label> | ||
<input | ||
type="password" | ||
id={PASSWORD} | ||
name={PASSWORD} | ||
style={styles.textbox} | ||
/> | ||
</div> | ||
<div style={styles.row}> | ||
<button type="submit" style={styles.button}>CREATE</button> | ||
</div> | ||
</form> | ||
</div> | ||
<div> | ||
<a href={paths.loginPath} style={styles.link}>LOGIN</a> | ||
</div> | ||
</div> | ||
); | ||
}; | ||
} |