Skip to content

Instantly share code, notes, and snippets.

@oezguerisbert
Created April 28, 2024 10:47
Show Gist options
  • Select an option

  • Save oezguerisbert/99a4f21259f89a0d38c1542ba8915c10 to your computer and use it in GitHub Desktop.

Select an option

Save oezguerisbert/99a4f21259f89a0d38c1542ba8915c10 to your computer and use it in GitHub Desktop.
Auth Stuff with Lucia + SST + SolidStart
import { Lucia, TimeSpan } from "lucia";
import { luciaAdapter } from "@/core/src/drizzle/sql";
import type { SessionSelect, UserSelect } from "@/core/src/drizzle/sql/schema";
export const lucia = new Lucia(luciaAdapter, {
sessionExpiresIn: new TimeSpan(2, "w"),
sessionCookie: {
attributes: {
// set to `true` when using HTTPS
secure: import.meta.env.PROD,
},
},
getUserAttributes: (attributes) => {
return {
username: attributes.name,
email: attributes.email,
};
},
getSessionAttributes(databaseSessionAttributes) {
return {
access_token: databaseSessionAttributes.access_token,
createdAt: databaseSessionAttributes.createdAt,
// addiional information
// organization_id: databaseSessionAttributes.organization_id,
};
},
});
declare module "lucia" {
interface Register {
Lucia: typeof lucia;
DatabaseUserAttributes: DatabaseUserAttributes;
DatabaseSessionAttributes: DatabaseSessionAttributes;
}
}
type DatabaseUserAttributes = Omit<UserSelect, "id">;
type DatabaseSessionAttributes = Omit<SessionSelect, "id" | "userID" | "expiresAt" | "userId" | "updatedAt">;
import { Organization } from "@/core/src/entities/organizations";
import { User } from "@/core/src/entities/users";
import { cache, redirect } from "@solidjs/router";
import { getCookie, getEvent } from "vinxi/http";
import { lucia } from ".";
export const getAuthenticatedUser = cache(async () => {
"use server";
const event = getEvent()!;
if (!event.context.session) {
return null;
}
const { id } = event.context.session;
const { user } = await lucia.validateSession(id);
return user;
}, "user");
export type UserSession = {
id: string | null;
token: string | null;
expiresAt: Date | null;
user: Awaited<ReturnType<typeof User.findById>> | null;
organization: Awaited<ReturnType<typeof Organization.findById>> | null;
createdAt: Date | null;
};
export const getAuthenticatedSession = cache(async () => {
"use server";
let userSession = {
id: null,
token: null,
expiresAt: null,
user: null,
organization: null,
createdAt: null,
} as UserSession;
const event = getEvent()!;
const sessionId = getCookie(event, lucia.sessionCookieName) ?? null;
if (!sessionId) {
// throw redirect("/auth/login");
return userSession;
}
const { session } = await lucia.validateSession(sessionId);
if (!session) {
// throw redirect("/auth/login");
// console.error("invalid session");
return userSession;
}
userSession.id = session.id;
if (session.userId) userSession.user = await User.findById(session.userId);
if (session.createdAt) userSession.createdAt = session.createdAt;
// additional information
if (session.organization_id) userSession.organization = await Organization.findById(session.organization_id);
return userSession;
}, "session");
export const getAuthenticatedSessions = cache(async () => {
"use server";
const event = getEvent()!;
if (!event.context.user) {
return redirect("/auth/login");
}
const { id } = event.context.user;
const sessions = await lucia.getUserSessions(id);
return sessions;
}, "sessions");
export const getCurrentOrganization = cache(async () => {
"use server";
const event = getEvent()!;
if (!event.context.session) {
return redirect("/auth/login");
}
const { id } = event.context.session;
const { user, session } = await lucia.validateSession(id);
if (!user || !session) {
throw redirect("/auth/login");
}
if (!session.organization_id) {
throw redirect("/setup/organization");
}
const org = Organization.findById(session.organization_id);
if (!org) {
throw redirect("/setup/organization");
}
return org;
}, "current-organization");
import { lucia } from "@/lib/auth";
import type { APIEvent } from "@solidjs/start/server";
import { appendHeader, sendRedirect } from "vinxi/http";
export async function GET(e: APIEvent) {
const event = e.nativeEvent;
const url = new URL(e.request.url);
const code = url.searchParams.get("code");
const client_id = url.searchParams.get("client_id");
if (!client_id) {
return sendRedirect(event, "/auth/error?error=missing_client_id", 303);
}
if (!code) {
return sendRedirect(event, "/auth/error?error=missing_code", 303);
}
// console.log({ code });
const body = new URLSearchParams({
grant_type: "authorization_code",
client_id,
code,
redirect_uri: `${url.origin}${url.pathname}?client_id=${client_id}`,
});
const token = await fetch(`${import.meta.env.VITE_AUTH_URL}/token`, {
method: "POST",
body,
}).then((r) => r.json());
if (!token.access_token) {
return sendRedirect(event, "/auth/error?error=missing_access_token", 303);
}
const { id, organization_id } = await fetch(new URL("/session", import.meta.env.VITE_API_URL), {
headers: {
Authorization: `Bearer ${token.access_token}`,
},
}).then((r) => r.json());
if (!id) {
return sendRedirect(event, "/auth/error?error=missing_user", 303);
}
const session = await lucia.createSession(id, {
access_token: token.access_token,
organization_id,
createdAt: new Date(),
});
appendHeader(event, "Set-Cookie", lucia.createSessionCookie(session.id).serialize());
event.context.session = session;
// everything works fine, redirect to dashboard
return sendRedirect(event, "/dashboard", 303);
}
import { Button, buttonVariants } from "@/components/ui/button";
import { Logo } from "@/components/ui/custom/logo";
import { TextField, TextFieldInput } from "@/components/ui/textfield";
import { cn } from "@/lib/utils";
import { A, useParams, useSearchParams } from "@solidjs/router";
import { Show, createEffect } from "solid-js";
import { For, createSignal } from "solid-js";
export default function ConfirmCodePage() {
const [submitting, setSubmitting] = createSignal<boolean>();
const [searchParams] = useSearchParams();
const isInvalidOrMissingEmail = () => searchParams.error && searchParams.error !== "invalid_or_missing_email";
function submit() {
setSubmitting(true);
const code = [...document.querySelectorAll("[data-element=code]")]
.map((el) => (el as HTMLInputElement).value)
.join("");
location.href =
import.meta.env.VITE_AUTH_URL +
"/callback?" +
new URLSearchParams({
code,
client_id: "email",
}).toString();
}
function inputs() {
return [...document.querySelectorAll<HTMLInputElement>("[data-element=code]")];
}
createEffect(() => {
const code = searchParams.code;
if (code) {
// set the code in the inputs
const _inputs = inputs();
_inputs.forEach((input, index) => {
input.value = code[index];
});
submit();
}
});
return (
<div class="container h-screen flex flex-col items-center justify-center px-10">
<div class="w-full h-[650px] -mt-60">
<div class="w-full relative flex h-full flex-col items-center justify-center lg:px-0">
<div class="mx-auto flex w-max flex-col justify-center space-y-6 p-4 py-20 border rounded-md border-neutral-200 dark:border-neutral-800 shadow-md gap-10">
<div class="flex flex-row gap-4 items-center w-full justify-center text-lg font-bold">
<Logo /> Login
</div>
<Show
when={!isInvalidOrMissingEmail()}
fallback={
<div class="flex flex-col gap-8 items-center w-full justify-center px-10">
<span class="text-sm text-muted-foreground">Please provide a valid email address</span>
<A class={cn(buttonVariants({ variant: "default", size: "sm" }))} href="/auth/login">
<span>Try again</span>
</A>
</div>
}
>
<div class="flex flex-col gap-8 items-center w-full justify-center">
<form
class="flex flex-col gap-8 items-center w-full justify-center px-4"
action={import.meta.env.VITE_AUTH_URL + "/authorize?provider=email"}
method="get"
onSubmit={async (e) => {
setSubmitting(true);
e.preventDefault();
const form = e.currentTarget;
form.submit();
}}
>
<div class="flex flex-row gap-4 items-center w-full justify-center">
<For each={Array(6).fill(0)}>
{() => (
<TextField>
<TextFieldInput
data-element="code"
class="w-10 text-center"
maxLength={1}
inputmode="numeric"
disabled={submitting()}
type="text"
onPaste={(e) => {
const code = e.clipboardData?.getData("text/plain")?.trim();
if (!code) return;
const i = inputs();
if (code.length !== i.length) return;
i.forEach((item, index) => {
item.value = code[index];
});
e.preventDefault();
submit();
}}
onFocus={(e) => {
e.currentTarget.select();
}}
onKeyDown={(e) => {
if (!e.currentTarget.value && e.key === "Backspace") {
e.preventDefault();
const previous =
e.currentTarget.parentNode?.parentNode?.previousSibling?.firstChild?.firstChild;
if (previous instanceof HTMLInputElement) {
previous.focus();
}
return;
}
}}
onInput={(e) => {
const all = inputs();
const index = all.indexOf(e.currentTarget);
if (!e.currentTarget.value) {
const previous = all[index - 1];
if (previous) {
previous.focus();
}
return;
}
const next = all[index + 1];
if (next) {
next.focus();
next.select();
return;
}
if (!next) submit();
}}
></TextFieldInput>
</TextField>
)}
</For>
</div>
<Button
variant="default"
size="lg"
type="submit"
class={cn("w-full", {
"opacity-50 cursor-not-allowed": submitting(),
})}
aria-busy={submitting()}
aria-label="Continue with Email"
disabled={submitting()}
>
<span>Confirm</span>
</Button>
</form>
<div class="px-8 text-center text-sm text-muted-foreground gap-2 flex flex-col">
<span>By continuing, you agree to our</span>
<div class="">
<A href="/terms-of-service" class="underline underline-offset-4 hover:text-primary">
Terms of Service
</A>{" "}
and{" "}
<A href="/privacy" class="underline underline-offset-4 hover:text-primary">
Privacy Policy
</A>
.
</div>
</div>
</div>
</Show>
</div>
</div>
</div>
</div>
);
}
import { As } from "@kobalte/core";
import { A, useSearchParams } from "@solidjs/router";
import { Button, buttonVariants } from "../../components/ui/button";
import { cn } from "../../lib/utils";
export default function LoginErrorPage() {
const [sp] = useSearchParams();
const error = sp.error || "unknown";
return (
<div class="w-full h-screen flex flex-col items-center justify-center">
<div class="w-full h-[650px] -mt-60">
<div class="w-full relative flex h-full flex-col items-center justify-center lg:px-0">
<div class="mx-auto flex w-max flex-col justify-center space-y-6 p-4 py-20 border rounded-md border-neutral-200 dark:border-neutral-800 shadow-md gap-10">
<div class="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
<div class="flex flex-col space-y-2 text-center">
<h1 class="text-2xl font-semibold tracking-tight">Upps some error occured</h1>
<p class="text-sm text-muted-foreground">
{error === "invalid_code" && "The code is invalid"}
{error === "missing_access_token" && "The access token is missing"}
{error === "missing_user" && "The user is missing"}
{error === "unknown" && "An unknown error occured"}
</p>
</div>
<A
class={cn(
buttonVariants({
variant: "default",
size: "lg",
})
)}
aria-label="Go to the login page"
href="/auth/login"
>
<span>Login again</span>
</A>
</div>
</div>
</div>
</div>
</div>
);
}
import { Button } from "@/components/ui/button";
import { Logo } from "@/components/ui/custom/logo";
import { TextField, TextFieldInput, TextFieldLabel } from "@/components/ui/textfield";
import { cn } from "@/lib/utils";
import { As } from "@kobalte/core";
import { A, useNavigate } from "@solidjs/router";
import type { SVGAttributes } from "lucide-solid/dist/types/types";
import { For, JSX, createSignal } from "solid-js";
import { toast } from "solid-sonner";
const generateAuthUrl = (provider: string) => {
const url = new URL("/authorize", import.meta.env.VITE_AUTH_URL);
url.searchParams.set("provider", provider);
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", provider);
url.searchParams.set(
"redirect_uri",
(import.meta.env.NODE_ENV === "production" ? "https://<your-domain>" : "http://localhost:3000") +
"/api/auth/callback"
);
return url.toString();
};
const logins = {
google: generateAuthUrl("google"),
} as const;
export type Logins = keyof typeof logins;
const logos: Record<Logins, (props: SVGAttributes) => JSX.Element> = {
google: (props: SVGAttributes) => (
<svg {...props} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M12 13.9V10.18H21.36C21.5 10.81 21.61 11.4 21.61 12.23C21.61 17.94 17.78 22 12.01 22C6.48 22 2 17.52 2 12C2 6.48 6.48 2 12 2C14.7 2 16.96 2.99 18.69 4.61L15.85 7.37C15.13 6.69 13.88 5.88 12 5.88C8.69 5.88 5.99 8.63 5.99 12C5.99 15.37 8.69 18.12 12 18.12C15.83 18.12 17.24 15.47 17.5 13.9H12Z"
fill="currentColor"
></path>
</svg>
),
};
export default function LoginPage() {
const [email, setEmail] = createSignal<string>("");
const [submitting, setSubmitting] = createSignal<boolean>();
return (
<div class="container h-[calc(100vh-49px)] flex flex-col items-center justify-center px-10">
<div class="w-full h-[650px] -mt-60 border border-neutral-200 dark:border-neutral-800 rounded-lg overflow-clip">
<div class="w-full relative flex h-full flex-col items-center justify-center md:grid lg:max-w-none lg:grid-cols-2 lg:px-0">
<div class="relative hidden h-full flex-col bg-muted p-10 dark:border-r lg:flex">
<div class="absolute inset-0 bg-neutral-100 dark:bg-neutral-900" />
<div class="relative z-20 flex items-center text-lg font-medium gap-2">
<Logo />
Portal
</div>
<div class="relative z-20 mt-auto">
</div>
</div>
<div class="p-8 w-full">
<div class="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
<div class="relative z-20 flex lg:hidden items-center text-lg font-medium gap-2 justify-center">
<Logo />
Portal
</div>
<div class="flex flex-col space-y-4 text-center">
<h1 class="text-2xl font-semibold tracking-tight">Create an account</h1>
<p class="text-sm text-muted-foreground">Enter your email below to create your account</p>
</div>
<div class="flex flex-col gap-4 items-center w-full">
<TextField class="w-full" name="email" value={email()}>
<TextFieldInput
type="email"
name="email"
placeholder="name@example.com"
autoCapitalize="none"
autocomplete="email"
autocorrect="off"
class="w-full"
disabled={submitting()}
onInput={(e) => setEmail(e.currentTarget.value)}
/>
</TextField>
<Button
variant="default"
size="lg"
class={cn("w-full", {
"opacity-50 cursor-not-allowed": submitting(),
})}
aria-busy={submitting()}
aria-label="Continue with Email"
disabled={submitting()}
onClick={async () => {
const _e = email();
setSubmitting(true);
window.location.href =
import.meta.env.VITE_AUTH_URL +
"/authorize?" +
new URLSearchParams({
client_id: "email",
redirect_uri: import.meta.env.VITE_APP_URL + "/api/auth/callback?client_id=email",
response_type: "code",
provider: "email",
email: _e,
});
}}
>
<span>Continue with Email</span>
</Button>
<span class="text-muted-foreground text-sm">We'll send a pin code to your email</span>
</div>
<div class="relative">
<div class="absolute inset-0 flex items-center">
<span class="w-full border-t" />
</div>
<div class="relative flex justify-center text-xs uppercase">
<span class="bg-background px-2 text-muted-foreground">Or continue with</span>
</div>
</div>
<div class="flex flex-col gap-4 items-center w-full">
<For each={Object.entries(logins) as [Logins, string][]}>
{([provider, url]) => {
const L = logos[provider];
return (
<Button asChild variant="default" size="lg" class="!w-full">
<As
component={A}
href={url}
class="flex items-center justify-center w-max text-sm font-medium gap-4 capitalize"
>
<L class="h-5 w-5" />
<span>{provider}</span>
</As>
</Button>
);
}}
</For>
</div>
<div class="px-8 text-center text-sm text-muted-foreground gap-4 flex flex-col">
<span>By continuing, you agree to our</span>
<div class="">
<A href="/terms-of-service" class="underline underline-offset-4 hover:text-primary">
Terms of Service
</A>{" "}
and{" "}
<A href="/privacy" class="underline underline-offset-4 hover:text-primary">
Privacy Policy
</A>
.
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
import { Context } from "sst/context/context2.js";
import { z } from "zod";
export const PublicActor = z.object({
type: z.literal("public"),
properties: z.object({}),
});
export type PublicActor = z.infer<typeof PublicActor>;
export const AccountActor = z.object({
type: z.literal("account"),
properties: z.object({
accountID: z.string().cuid2(),
email: z.string().nonempty(),
}),
});
export type AccountActor = z.infer<typeof AccountActor>;
export const UserActor = z.object({
type: z.literal("user"),
properties: z.object({
userID: z.string().uuid(),
workspaceID: z.string().uuid(),
}),
});
export type UserActor = z.infer<typeof UserActor>;
export const SystemActor = z.object({
type: z.literal("system"),
properties: z.object({
workspaceID: z.string().uuid(),
}),
});
export type SystemActor = z.infer<typeof SystemActor>;
export const Actor = z.discriminatedUnion("type", [UserActor, AccountActor, PublicActor, SystemActor]);
export type Actor = z.infer<typeof Actor>;
const ActorContext = Context.create<Actor>("actor");
export const useActor = ActorContext.use;
export const withActor = ActorContext.with;
export function assertActor<T extends Actor["type"]>(type: T) {
const actor = useActor();
if (actor.type !== type) {
throw new Error(`Expected actor type ${type}, got ${actor.type}`);
}
return actor as Extract<Actor, { type: T }>;
}
import { User } from "@/core/entities/users";
import { ApiHandler } from "sst/node/api";
import { Config } from "sst/node/config";
import { AuthHandler, CodeAdapter, GoogleAdapter } from "sst/node/future/auth";
import { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2";
import { error, getUser, json, sessions } from "./utils";
import { z } from "zod";
import { withActor } from "@/core/actor";
import Nodemailer from "nodemailer";
export const handler = AuthHandler({
sessions,
providers: {
google: GoogleAdapter({
clientID: Config.GOOGLE_CLIENT_ID,
mode: "oidc",
}),
email: CodeAdapter({
async onCodeRequest(code, claims) {
console.log("code request", code, claims);
return withActor(
{
type: "public",
properties: {},
},
async () => {
console.log("sending email to", claims);
console.log("code", code);
const email = z.string().email().safeParse(claims.email);
if (!email.success) {
console.log("invalid email, aborting", claims);
return {
statusCode: 302,
headers: {
Location: process.env.AUTH_FRONTEND_URL + "/auth/email?error=invalid_or_missing_email",
},
};
}
// TODO!: implement a better way to verify the email, and botspam.
const ok = true;
if (!ok)
return {
statusCode: 302,
headers: {
Location: process.env.AUTH_FRONTEND_URL + "/auth/email?error=invalid_code",
},
};
const nodemailer = Nodemailer.createTransport({
host: Config.EMAIL_HOST,
port: z.coerce.number().parse(Config.EMAIL_PORT),
secure: process.env.IS_LOCAL ? false : true,
auth: {
user: Config.EMAIL_USERNAME,
pass: Config.EMAIL_PASSWORD,
},
});
// send via mail, you can use whatever api you want for it
const info = await nodemailer
.sendMail({
from: Config.EMAIL_FROM,
to: claims.email,
subject: "Your Pin Code: " + code,
text: "Your pin code is " + code,
html: "Your pin code is <strong>" + code + "</strong>",
})
.catch((err) => {
console.error("error sending email", err);
return null;
});
if (!info) {
return {
statusCode: 302,
headers: {
Location: process.env.AUTH_FRONTEND_URL + "/auth/email?error=sending_email_failed",
},
};
} else {
console.log("send message info", info);
return {
statusCode: 302,
headers: {
Location: process.env.AUTH_FRONTEND_URL + "/auth/email",
},
};
}
}
);
},
async onCodeInvalid() {
return {
statusCode: 302,
headers: {
Location: process.env.AUTH_FRONTEND_URL + "/auth/error?error=invalid_code",
},
};
},
}),
},
callbacks: {
error: async (e) => {
console.log("upps error: ", e);
return {
statusCode: 302,
headers: {
Location: process.env.AUTH_FRONTEND_URL + "/auth/error?error=unknown",
},
};
},
auth: {
async allowClient(clientID, redirect) {
const clients = ["solid", "google", "email"];
if (!clients.includes(clientID)) {
return false;
}
return true;
},
async error(error) {
console.log("auth-error", error);
return {
statusCode: 302,
headers: {
Location: process.env.AUTH_FRONTEND_URL + "/auth/error?error=unknown",
},
};
},
async success(input, response) {
if (input.provider === "google") {
const claims = input.tokenset.claims();
const email = claims.email;
const name = claims.preferred_username ?? claims.name;
if (!email || !name) {
console.error("No email or name found in tokenset", input.tokenset);
return response.http({
statusCode: 400,
body: "No email found in tokenset",
});
}
let user_ = await User.findByEmail(email);
if (!user_) {
user_ = await User.create({ email, name });
}
await User.update({ id: user_.id, deletedAt: null });
return response.session({
type: "user",
properties: {
id: user_.id,
email: user_.email,
},
});
}
if (input.provider === "email") {
const claims = input.claims;
const email = claims.email;
if (!email) {
console.error("No email or name found in claims", input.claims);
return response.http({
statusCode: 400,
body: "No email found in claims",
});
}
let user_ = await User.findByEmail(email);
if (!user_) {
user_ = await User.create({ email, name: email });
}
await User.update({ id: user_.id, deletedAt: null });
return response.session({
type: "user",
properties: {
id: user_.id,
email: user_.email,
},
});
}
throw new Error("Unknown provider");
},
},
},
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment