Integrate Lucia Auth in Wasp (#1625)

This commit is contained in:
Mihovil Ilakovac 2024-01-15 11:53:54 +01:00 committed by GitHub
parent ffe2509cb4
commit 73c37b7d6d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
97 changed files with 1117 additions and 600 deletions

View File

@ -8,59 +8,60 @@ const api = axios.create({
baseURL: config.apiUrl,
})
const WASP_APP_AUTH_TOKEN_NAME = 'authToken'
const WASP_APP_AUTH_SESSION_ID_NAME = 'sessionId'
let authToken = storage.get(WASP_APP_AUTH_TOKEN_NAME) as string | undefined
let waspAppAuthSessionId = storage.get(WASP_APP_AUTH_SESSION_ID_NAME) as string | undefined
export function setAuthToken(token: string): void {
authToken = token
storage.set(WASP_APP_AUTH_TOKEN_NAME, token)
apiEventsEmitter.emit('authToken.set')
export function setSessionId(sessionId: string): void {
waspAppAuthSessionId = sessionId
storage.set(WASP_APP_AUTH_SESSION_ID_NAME, sessionId)
apiEventsEmitter.emit('sessionId.set')
}
export function getAuthToken(): string | undefined {
return authToken
export function getSessionId(): string | undefined {
return waspAppAuthSessionId
}
export function clearAuthToken(): void {
authToken = undefined
storage.remove(WASP_APP_AUTH_TOKEN_NAME)
apiEventsEmitter.emit('authToken.clear')
export function clearSessionId(): void {
waspAppAuthSessionId = undefined
storage.remove(WASP_APP_AUTH_SESSION_ID_NAME)
apiEventsEmitter.emit('sessionId.clear')
}
export function removeLocalUserData(): void {
authToken = undefined
waspAppAuthSessionId = undefined
storage.clear()
apiEventsEmitter.emit('authToken.clear')
apiEventsEmitter.emit('sessionId.clear')
}
api.interceptors.request.use((request) => {
if (authToken) {
request.headers['Authorization'] = `Bearer ${authToken}`
const sessionId = getSessionId()
if (sessionId) {
request.headers['Authorization'] = `Bearer ${sessionId}`
}
return request
})
api.interceptors.response.use(undefined, (error) => {
if (error.response?.status === 401) {
clearAuthToken()
clearSessionId()
}
return Promise.reject(error)
})
// This handler will run on other tabs (not the active one calling API functions),
// and will ensure they know about auth token changes.
// and will ensure they know about auth session ID changes.
// Ref: https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event
// "Note: This won't work on the same page that is making the changes — it is really a way
// for other pages on the domain using the storage to sync any changes that are made."
window.addEventListener('storage', (event) => {
if (event.key === storage.getPrefixedKey(WASP_APP_AUTH_TOKEN_NAME)) {
if (event.key === storage.getPrefixedKey(WASP_APP_AUTH_SESSION_ID_NAME)) {
if (!!event.newValue) {
authToken = event.newValue
apiEventsEmitter.emit('authToken.set')
waspAppAuthSessionId = event.newValue
apiEventsEmitter.emit('sessionId.set')
} else {
authToken = undefined
apiEventsEmitter.emit('authToken.clear')
waspAppAuthSessionId = undefined
apiEventsEmitter.emit('sessionId.clear')
}
}
})

View File

@ -3,9 +3,9 @@ import mitt, { Emitter } from 'mitt';
type ApiEvents = {
// key: Event name
// type: Event payload type
'authToken.set': void;
'authToken.clear': void;
'sessionId.set': void;
'sessionId.clear': void;
};
// Used to allow API clients to register for auth token change events.
// Used to allow API clients to register for auth session ID change events.
export const apiEventsEmitter: Emitter<ApiEvents> = mitt<ApiEvents>();

View File

@ -5,7 +5,7 @@ import { initSession } from '../../helpers/user';
export async function login(data: { email: string; password: string }): Promise<void> {
try {
const response = await api.post('{= loginPath =}', data);
await initSession(response.data.token);
await initSession(response.data.sessionId);
} catch (e) {
handleApiError(e);
}

View File

@ -1,8 +1,8 @@
import { setAuthToken } from '../../api'
import { setSessionId } from '../../api'
import { invalidateAndRemoveQueries } from '../../operations/resources'
export async function initSession(token: string): Promise<void> {
setAuthToken(token)
export async function initSession(sessionId: string): Promise<void> {
setSessionId(sessionId)
// We need to invalidate queries after login in order to get the correct user
// data in the React components (using `useAuth`).
// Redirects after login won't work properly without this.

View File

@ -7,7 +7,7 @@ export default async function login(username: string, password: string): Promise
const args = { username, password }
const response = await api.post('{= loginPath =}', args)
await initSession(response.data.token)
await initSession(response.data.sessionId)
} catch (error) {
handleApiError(error)
}

View File

@ -1,9 +1,17 @@
import { removeLocalUserData } from '../api'
import api, { removeLocalUserData } from '../api'
import { invalidateAndRemoveQueries } from '../operations/resources'
export default async function logout(): Promise<void> {
removeLocalUserData()
// TODO(filip): We are currently invalidating and removing all the queries, but
// we should remove only the non-public, user-dependent ones.
await invalidateAndRemoveQueries()
try {
await api.post('/auth/logout')
} finally {
// Even if the logout request fails, we still want to remove the local user data
// in case the logout failed because of a network error and the user walked away
// from the computer.
removeLocalUserData()
// TODO(filip): We are currently invalidating and removing all the queries, but
// we should remove only the non-public, user-dependent ones.
await invalidateAndRemoveQueries()
}
}

View File

@ -29,7 +29,7 @@ export default function OAuthCodeExchange({ pathToApiServerRouteHandlingOauthRed
// This helps us reuse one component for various methods (e.g., Google, Facebook, etc.).
const apiServerUrlHandlingOauthRedirect = constructOauthRedirectApiServerUrl(pathToApiServerRouteHandlingOauthRedirect)
exchangeCodeForJwtAndRedirect(history, apiServerUrlHandlingOauthRedirect)
exchangeCodeForSessionIdAndRedirect(history, apiServerUrlHandlingOauthRedirect)
return () => {
firstRender.current = false
}
@ -47,22 +47,22 @@ function constructOauthRedirectApiServerUrl(pathToApiServerRouteHandlingOauthRed
return `${config.apiUrl}${pathToApiServerRouteHandlingOauthRedirect}${queryParams}`
}
async function exchangeCodeForJwtAndRedirect(history, apiServerUrlHandlingOauthRedirect) {
const token = await exchangeCodeForJwt(apiServerUrlHandlingOauthRedirect)
async function exchangeCodeForSessionIdAndRedirect(history, apiServerUrlHandlingOauthRedirect) {
const sessionId = await exchangeCodeForSessionId(apiServerUrlHandlingOauthRedirect)
if (token !== null) {
await initSession(token)
if (sessionId !== null) {
await initSession(sessionId)
history.push('{= onAuthSucceededRedirectTo =}')
} else {
console.error('Error obtaining JWT token')
console.error('Error obtaining session ID')
history.push('{= onAuthFailedRedirectTo =}')
}
}
async function exchangeCodeForJwt(url) {
async function exchangeCodeForSessionId(url) {
try {
const response = await api.get(url)
return response?.data?.token || null
return response?.data?.sessionId || null
} catch (e) {
console.error(e)
return null

View File

@ -1,2 +1,2 @@
// todo(filip): turn into a proper import/path
export type { SanitizedUser as User, ProviderName, DeserializedAuthEntity } from '../../../server/src/_types/'
export type { SanitizedUser as User, ProviderName, DeserializedAuthIdentity } from '../../../server/src/_types/'

View File

@ -2,7 +2,7 @@
// We have them duplicated in this file and in data/Generator/templates/server/src/auth/user.ts
// If you are changing the logic here, make sure to change it there as well.
import type { User, ProviderName, DeserializedAuthEntity } from './types'
import type { User, ProviderName, DeserializedAuthIdentity } from './types'
export function getEmail(user: User): string | null {
return findUserIdentity(user, "email")?.providerUserId ?? null;
@ -20,7 +20,7 @@ export function getFirstProviderUserId(user?: User): string | null {
return user.auth.identities[0].providerUserId ?? null;
}
export function findUserIdentity(user: User, providerName: ProviderName): DeserializedAuthEntity | undefined {
export function findUserIdentity(user: User, providerName: ProviderName): DeserializedAuthIdentity | undefined {
return user.auth.identities.find(
(identity) => identity.providerName === providerName
);

View File

@ -2,7 +2,7 @@
import { createContext, useState, useEffect } from 'react'
import { io, Socket } from 'socket.io-client'
import { getAuthToken } from '../api'
import { getSessionId } from '../api'
import { apiEventsEmitter } from '../api/events'
import config from '../config'
@ -16,7 +16,7 @@ function refreshAuthToken() {
// NOTE: When we figure out how `auth: true` works for Operations, we should
// mirror that behavior here for WebSockets. Ref: https://github.com/wasp-lang/wasp/issues/1133
socket.auth = {
token: getAuthToken()
sessionId: getSessionId()
}
if (socket.connected) {
@ -26,8 +26,8 @@ function refreshAuthToken() {
}
refreshAuthToken()
apiEventsEmitter.on('authToken.set', refreshAuthToken)
apiEventsEmitter.on('authToken.clear', refreshAuthToken)
apiEventsEmitter.on('sessionId.set', refreshAuthToken)
apiEventsEmitter.on('sessionId.clear', refreshAuthToken)
export const WebSocketContext = createContext({
socket,

View File

@ -88,18 +88,18 @@ type Context<Entities extends _Entity[]> = Expand<{
{=# isAuthEnabled =}
type ContextWithUser<Entities extends _Entity[]> = Expand<Context<Entities> & { user?: SanitizedUser }>
// TODO: This type must match the logic in core/auth.js (if we remove the
// TODO: This type must match the logic in auth/session.js (if we remove the
// password field from the object there, we must do the same here). Ideally,
// these two things would live in the same place:
// https://github.com/wasp-lang/wasp/issues/965
export type DeserializedAuthEntity = Expand<Omit<{= authIdentityEntityName =}, 'providerData'> & {
export type DeserializedAuthIdentity = Expand<Omit<{= authIdentityEntityName =}, 'providerData'> & {
providerData: Omit<EmailProviderData, 'password'> | Omit<UsernameProviderData, 'password'> | OAuthProviderData
}>
export type SanitizedUser = {= userEntityName =} & {
{= authFieldOnUserEntityName =}: {= authEntityName =} & {
{= identitiesFieldOnAuthEntityName =}: DeserializedAuthEntity[]
{= identitiesFieldOnAuthEntityName =}: DeserializedAuthIdentity[]
} | null
}

View File

@ -0,0 +1,12 @@
import jwt from 'jsonwebtoken'
import util from 'util'
import config from '../config.js'
const jwtSign = util.promisify(jwt.sign)
const jwtVerify = util.promisify(jwt.verify)
const JWT_SECRET = config.auth.jwtSecret
export const signData = (data, options) => jwtSign(data, JWT_SECRET, options)
export const verify = (token) => jwtVerify(token, JWT_SECRET)

View File

@ -0,0 +1,56 @@
{{={= =}=}}
import { Lucia } from "lucia";
import { PrismaAdapter } from "@lucia-auth/adapter-prisma";
import prisma from '../dbClient.js'
import config from '../config.js'
import { type {= userEntityUpper =} } from "../entities/index.js"
const prismaAdapter = new PrismaAdapter(
// Using `as any` here since Lucia's model types are not compatible with Prisma 4
// model types. This is a temporary workaround until we migrate to Prisma 5.
// This **works** in runtime, but Typescript complains about it.
prisma.{= sessionEntityLower =} as any,
prisma.{= authEntityLower =} as any
);
/**
* We are using Lucia for session management.
*
* Some details:
* 1. We are using the Prisma adapter for Lucia.
* 2. We are not using cookies for session management. Instead, we are using
* the Authorization header to send the session token.
* 3. Our `Session` entity is connected to the `Auth` entity.
* 4. We are exposing the `userId` field from the `Auth` entity to
* make fetching the User easier.
*/
export const auth = new Lucia<{}, {
userId: {= userEntityUpper =}['id']
}>(prismaAdapter, {
// Since we are not using cookies, we don't need to set any cookie options.
// But in the future, if we decide to use cookies, we can set them here.
// sessionCookie: {
// name: "session",
// expires: true,
// attributes: {
// secure: !config.isDevelopment,
// sameSite: "lax",
// },
// },
getUserAttributes({ userId }) {
return {
userId,
};
},
});
declare module "lucia" {
interface Register {
Lucia: typeof auth;
DatabaseSessionAttributes: {};
DatabaseUserAttributes: {
userId: {= userEntityUpper =}['id']
};
}
}

View File

@ -0,0 +1,15 @@
import SecurePassword from 'secure-password'
const SP = new SecurePassword()
export const hashPassword = async (password: string): Promise<string> => {
const hashedPwdBuffer = await SP.hash(Buffer.from(password))
return hashedPwdBuffer.toString("base64")
}
export const verifyPassword = async (hashedPassword: string, password: string): Promise<void> => {
const result = await SP.verify(Buffer.from(password), Buffer.from(hashedPassword, "base64"))
if (result !== SecurePassword.VALID) {
throw new Error('Invalid password.')
}
}

View File

@ -1,13 +1,14 @@
import { Request, Response } from 'express';
import { verifyPassword, throwInvalidCredentialsError } from "../../../core/auth.js";
import { throwInvalidCredentialsError } from '../../utils.js'
import { verifyPassword } from '../../password.js'
import {
createProviderId,
findAuthIdentity,
findAuthWithUserBy,
createAuthToken,
deserializeAndSanitizeProviderData,
} from "../../utils.js";
import { ensureValidEmail, ensurePasswordIsPresent } from "../../validation.js";
} from '../../utils.js'
import { createSession } from '../../session.js'
import { ensureValidEmail, ensurePasswordIsPresent } from '../../validation.js'
export function getLoginRoute({
allowUnverifiedLogin,
@ -17,7 +18,7 @@ export function getLoginRoute({
return async function login(
req: Request<{ email: string; password: string; }>,
res: Response,
): Promise<Response<{ token: string } | undefined>> {
): Promise<Response<{ sessionId: string } | undefined>> {
const fields = req.body ?? {}
ensureValidArgs(fields)
@ -38,9 +39,11 @@ export function getLoginRoute({
}
const auth = await findAuthWithUserBy({ id: authIdentity.authId })
const token = await createAuthToken(auth.userId)
const session = await createSession(auth.id)
return res.json({ token })
return res.json({
sessionId: session.id,
})
};
}

View File

@ -116,7 +116,9 @@ export function getSignupRoute({
await createUser(
providerId,
newUserProviderData,
userFields,
// Using any here because we want to avoid TypeScript errors and
// rely on Prisma to validate the data.
userFields as any
);
} catch (e: unknown) {
rethrowPossibleAuthError(e);

View File

@ -1,5 +1,5 @@
{{={= =}=}}
import { signData } from '../../../core/auth.js'
import { signData } from '../../jwt.js'
import { emailSender } from '../../../email/index.js';
import { Email } from '../../../email/core/types.js';
import {

View File

@ -13,11 +13,11 @@ import {
contextWithUserEntity,
createUser,
findAuthWithUserBy,
createAuthToken,
rethrowPossibleAuthError,
sanitizeAndSerializeProviderData,
} from "../../utils.js"
import { type {= userEntityUpper =} } from "../../../entities/index.js"
import { createSession } from "../../session.js"
import { type {= authEntityUpper =} } from "../../../entities/index.js"
import type { ProviderConfig, RequestWithWasp } from "../types.js"
import type { GetUserFieldsFn } from "./types.js"
import { handleRejection } from "../../../utils.js"
@ -53,9 +53,11 @@ export function createRouter(provider: ProviderConfig, initData: { passportStrat
const providerId = createProviderId(provider.id, providerProfile.id);
try {
const userId = await getUserIdFromProviderDetails(providerId, providerProfile, getUserFieldsFn)
const token = await createAuthToken(userId)
res.json({ token })
const authId = await getAuthIdFromProviderDetails(providerId, providerProfile, getUserFieldsFn)
const session = await createSession(authId)
return res.json({
sessionId: session.id,
})
} catch (e) {
rethrowPossibleAuthError(e)
}
@ -67,11 +69,11 @@ export function createRouter(provider: ProviderConfig, initData: { passportStrat
// We need a user id to create the auth token, so we either find an existing user
// or create a new one if none exists for this provider.
async function getUserIdFromProviderDetails(
async function getAuthIdFromProviderDetails(
providerId: ProviderId,
providerProfile: any,
getUserFieldsFn?: GetUserFieldsFn,
): Promise<{= userEntityUpper =}['id']> {
): Promise<{= authEntityUpper =}['id']> {
const existingAuthIdentity = await prisma.{= authIdentityEntityLower =}.findUnique({
where: {
providerName_providerUserId: providerId,
@ -86,7 +88,7 @@ async function getUserIdFromProviderDetails(
})
if (existingAuthIdentity) {
return existingAuthIdentity.{= authFieldOnAuthIdentityEntityName =}.{= userFieldOnAuthEntityName =}.id
return existingAuthIdentity.{= authFieldOnAuthIdentityEntityName =}.id
} else {
const userFields = getUserFieldsFn
? await getUserFieldsFn(contextWithUserEntity, { profile: providerProfile })
@ -101,6 +103,6 @@ async function getUserIdFromProviderDetails(
userFields,
)
return user.id
return user.auth.id
}
}

View File

@ -1,14 +1,15 @@
{{={= =}=}}
import { verifyPassword, throwInvalidCredentialsError } from '../../../core/auth.js'
import { throwInvalidCredentialsError } from '../../utils.js'
import { handleRejection } from '../../../utils.js'
import { verifyPassword } from '../../password.js'
import {
createProviderId,
findAuthIdentity,
findAuthWithUserBy,
createAuthToken,
deserializeAndSanitizeProviderData,
} from '../../utils.js'
import { createSession } from '../../session.js'
import { ensureValidUsername, ensurePasswordIsPresent } from '../../validation.js'
export default handleRejection(async (req, res) => {
@ -32,9 +33,12 @@ export default handleRejection(async (req, res) => {
const auth = await findAuthWithUserBy({
id: authIdentity.authId
})
const token = await createAuthToken(auth.userId)
return res.json({ token })
const session = await createSession(auth.id)
return res.json({
sessionId: session.id,
})
})
function ensureValidArgs(args: unknown): void {

View File

@ -0,0 +1,108 @@
{{={= =}=}}
import { Request as ExpressRequest } from "express";
import { type {= userEntityUpper =} } from "../entities/index.js"
import { type SanitizedUser } from '../_types/index.js'
import { auth } from "./lucia.js";
import type { Session } from "lucia";
import {
throwInvalidCredentialsError,
deserializeAndSanitizeProviderData,
} from "./utils.js";
import prisma from '../dbClient.js';
// Creates a new session for the `authId` in the database
export async function createSession(authId: string): Promise<Session> {
return auth.createSession(authId, {});
}
export async function getSessionAndUserFromBearerToken(req: ExpressRequest): Promise<{
user: SanitizedUser | null,
session: Session | null,
}> {
const authorizationHeader = req.headers["authorization"];
if (typeof authorizationHeader !== "string") {
return {
user: null,
session: null,
};
}
const sessionId = auth.readBearerToken(authorizationHeader);
if (!sessionId) {
return {
user: null,
session: null,
};
}
return getSessionAndUserFromSessionId(sessionId);
}
export async function getSessionAndUserFromSessionId(sessionId: string): Promise<{
user: SanitizedUser | null,
session: Session | null,
}> {
const { session, user: authEntity } = await auth.validateSession(sessionId);
if (!session || !authEntity) {
return {
user: null,
session: null,
};
}
return {
session,
user: await getUser(authEntity.userId)
}
}
async function getUser(userId: {= userEntityUpper =}['id']): Promise<SanitizedUser> {
const user = await prisma.{= userEntityLower =}
.findUnique({
where: { id: userId },
include: {
{= authFieldOnUserEntityName =}: {
include: {
{= identitiesFieldOnAuthEntityName =}: true
}
}
}
})
if (!user) {
throwInvalidCredentialsError()
}
// TODO: This logic must match the type in _types/index.ts (if we remove the
// password field from the object here, we must to do the same there).
// Ideally, these two things would live in the same place:
// https://github.com/wasp-lang/wasp/issues/965
const deserializedIdentities = user.{= authFieldOnUserEntityName =}.{= identitiesFieldOnAuthEntityName =}.map((identity) => {
const deserializedProviderData = deserializeAndSanitizeProviderData(
identity.providerData,
{
shouldRemovePasswordField: true,
}
)
return {
...identity,
providerData: deserializedProviderData,
}
})
return {
...user,
auth: {
...user.auth,
identities: deserializedIdentities,
},
}
}
export function invalidateSession(sessionId: string): Promise<void> {
return auth.invalidateSession(sessionId);
}

View File

@ -2,7 +2,7 @@
// We have them duplicated in this file and in data/Generator/templates/react-app/src/auth/user.ts
// If you are changing the logic here, make sure to change it there as well.
import type { SanitizedUser as User, ProviderName, DeserializedAuthEntity } from '../_types/index'
import type { SanitizedUser as User, ProviderName, DeserializedAuthIdentity } from '../_types/index'
export function getEmail(user: User): string | null {
return findUserIdentity(user, "email")?.providerUserId ?? null;
@ -20,7 +20,7 @@ export function getFirstProviderUserId(user?: User): string | null {
return user.auth.identities[0].providerUserId ?? null;
}
export function findUserIdentity(user: User, providerName: ProviderName): DeserializedAuthEntity | undefined {
export function findUserIdentity(user: User, providerName: ProviderName): DeserializedAuthIdentity | undefined {
return user.auth.identities.find(
(identity) => identity.providerName === providerName
);

View File

@ -1,5 +1,6 @@
{{={= =}=}}
import { hashPassword, sign, verify } from '../core/auth.js'
import { hashPassword } from './password.js'
import { verify } from './jwt.js'
import AuthError from '../core/AuthError.js'
import HttpError from '../core/HttpError.js'
import prisma from '../dbClient.js'
@ -137,7 +138,9 @@ export async function createUser(
providerId: ProviderId,
serializedProviderData?: string,
userFields?: PossibleAdditionalSignupFields,
): Promise<{= userEntityUpper =}> {
): Promise<{= userEntityUpper =} & {
auth: {= authEntityUpper =}
}> {
return prisma.{= userEntityLower =}.create({
data: {
// Using any here to prevent type errors when userFields are not
@ -154,7 +157,12 @@ export async function createUser(
},
}
},
}
},
// We need to include the Auth entity here because we need `authId`
// to be able to create a session.
include: {
{= authFieldOnUserEntityName =}: true,
},
})
}
@ -164,12 +172,6 @@ export async function deleteUserByAuthId(authId: string): Promise<{ count: numbe
} } })
}
export async function createAuthToken(
userId: {= userEntityUpper =}['id']
): Promise<string> {
return sign(userId);
}
export async function verifyToken<T = unknown>(token: string): Promise<T> {
return verify(token);
}
@ -297,3 +299,7 @@ function providerDataHasPasswordField(
): providerData is { hashedPassword: string } {
return 'hashedPassword' in providerData;
}
export function throwInvalidCredentialsError(message?: string): void {
throw new HttpError(401, 'Invalid credentials', { message })
}

View File

@ -1,24 +1,23 @@
{{={= =}=}}
import jwt from 'jsonwebtoken'
import SecurePassword from 'secure-password'
import util from 'util'
import { randomInt } from 'node:crypto'
import prisma from '../dbClient.js'
import { handleRejection } from '../utils.js'
import HttpError from '../core/HttpError.js'
import config from '../config.js'
import { deserializeAndSanitizeProviderData } from '../auth/utils.js'
const jwtSign = util.promisify(jwt.sign)
const jwtVerify = util.promisify(jwt.verify)
const JWT_SECRET = config.auth.jwtSecret
export const signData = (data, options) => jwtSign(data, JWT_SECRET, options)
export const sign = (id, options) => signData({ id }, options)
export const verify = (token) => jwtVerify(token, JWT_SECRET)
import { getSessionAndUserFromBearerToken } from '../auth/session.js'
import { throwInvalidCredentialsError } from '../auth/utils.js'
/**
* Auth middleware
*
* If the request includes an `Authorization` header it will try to authenticate the request,
* otherwise it will let the request through.
*
* - If authentication succeeds it sets `req.sessionId` and `req.user`
* - `req.user` is the user that made the request and it's used in
* all Wasp features that need to know the user that made the request.
* - `req.sessionId` is the ID of the session that authenticated the request.
* - If the request is not authenticated, it throws an error.
*/
const auth = handleRejection(async (req, res, next) => {
const authHeader = req.get('Authorization')
if (!authHeader) {
@ -28,69 +27,18 @@ const auth = handleRejection(async (req, res, next) => {
return next()
}
if (authHeader.startsWith('Bearer ')) {
const token = authHeader.substring(7, authHeader.length)
req.user = await getUserFromToken(token)
} else {
const { session, user } = await getSessionAndUserFromBearerToken(req);
if (!session || !user) {
throwInvalidCredentialsError()
}
req.sessionId = session.id
req.user = user
next()
})
export async function getUserFromToken(token) {
let userIdFromToken
try {
userIdFromToken = (await verify(token)).id
} catch (error) {
if (['TokenExpiredError', 'JsonWebTokenError', 'NotBeforeError'].includes(error.name)) {
throwInvalidCredentialsError()
} else {
throw error
}
}
const user = await prisma.{= userEntityLower =}
.findUnique({
where: { id: userIdFromToken },
include: {
{= authFieldOnUserEntityName =}: {
include: {
{= identitiesFieldOnAuthEntityName =}: true
}
}
}
})
if (!user) {
throwInvalidCredentialsError()
}
// TODO: This logic must match the type in types/index.ts (if we remove the
// password field from the object here, we must to do the same there).
// Ideally, these two things would live in the same place:
// https://github.com/wasp-lang/wasp/issues/965
let sanitizedUser = { ...user }
sanitizedUser.{= authFieldOnUserEntityName =}.{= identitiesFieldOnAuthEntityName =} = sanitizedUser.{= authFieldOnUserEntityName =}.{= identitiesFieldOnAuthEntityName =}.map(identity => {
identity.providerData = deserializeAndSanitizeProviderData(identity.providerData, { shouldRemovePasswordField: true })
return identity
});
return sanitizedUser
}
const SP = new SecurePassword()
export const hashPassword = async (password) => {
const hashedPwdBuffer = await SP.hash(Buffer.from(password))
return hashedPwdBuffer.toString("base64")
}
export const verifyPassword = async (hashedPassword, password) => {
const result = await SP.verify(Buffer.from(password), Buffer.from(hashedPassword, "base64"))
if (result !== SecurePassword.VALID) {
throw new Error('Invalid password.')
}
}
// Generates an unused username that looks similar to "quick-purple-sheep-91231".
// It generates several options and ensures it picks one that is not currently in use.
export function generateAvailableDictionaryUsername() {
@ -139,8 +87,4 @@ async function findAvailableUsername(potentialUsernames) {
return availableUsernames[0]
}
export function throwInvalidCredentialsError(message) {
throw new HttpError(401, 'Invalid credentials', { message })
}
export default auth

View File

@ -18,7 +18,7 @@ import type {
{= crud.entityUpper =},
} from "../entities";
{=# isAuthEnabled =}
import { throwInvalidCredentialsError } from "../core/auth.js";
import { throwInvalidCredentialsError } from '../auth/utils.js'
{=/ isAuthEnabled =}
{=# overrides.GetAll.isDefined =}
{=& overrides.GetAll.importStatement =}

View File

@ -0,0 +1,7 @@
// This is a polyfill for Node.js 18 webcrypto API so Lucia can use it
// for random number generation.
import { webcrypto } from "node:crypto";
// @ts-ignore
globalThis.crypto = webcrypto as Crypto;

View File

@ -3,12 +3,14 @@ import express from 'express'
import auth from '../../core/auth.js'
import me from './me.js'
import logout from './logout.js'
import providersRouter from '../../auth/providers/index.js'
const router = express.Router()
router.get('/me', auth, me)
router.post('/logout', auth, logout)
router.use('/', providersRouter)
export default router

View File

@ -0,0 +1,12 @@
import { handleRejection } from '../../utils.js'
import { throwInvalidCredentialsError } from '../../auth/utils.js'
import { invalidateSession } from '../../auth/session.js'
export default handleRejection(async (req, res) => {
if (req.sessionId) {
await invalidateSession(req.sessionId)
return res.json({ success: true })
} else {
throwInvalidCredentialsError()
}
})

View File

@ -1,7 +1,6 @@
{{={= =}=}}
import { serialize as superjsonSerialize } from 'superjson'
import { handleRejection } from '../../utils.js'
import { throwInvalidCredentialsError } from '../../core/auth.js'
import { throwInvalidCredentialsError } from '../../auth/utils.js'
export default handleRejection(async (req, res) => {
if (req.user) {

View File

@ -18,6 +18,8 @@ import './jobs/core/allJobs.js'
import { init as initWebSocket } from './webSocket/initialization.js'
{=/ userWebSocketFn.isDefined =}
import './polyfill.js'
const startServer = async () => {
{=# isPgBossJobExecutorUsed =}
await startPgBoss()

View File

@ -12,7 +12,8 @@ import { type SanitizedUser } from './_types/index.js'
type RequestWithExtraFields = Request & {
{=# isAuthEnabled =}
user?: SanitizedUser
user?: SanitizedUser;
sessionId?: string;
{=/ isAuthEnabled =}
}

View File

@ -8,7 +8,7 @@ import config from '../config.js'
import prisma from '../dbClient.js'
{=# isAuthEnabled =}
import { getUserFromToken } from '../core/auth.js'
import { getSessionAndUserFromSessionId } from '../auth/session.js'
{=/ isAuthEnabled =}
{=& userWebSocketFn.importStatement =}
@ -40,10 +40,11 @@ export async function init(server: http.Server): Promise<void> {
{=# isAuthEnabled =}
async function addUserToSocketDataIfAuthenticated(socket: Socket, next: (err?: Error) => void) {
const token = socket.handshake.auth.token
if (token) {
const sessionId = socket.handshake.auth.sessionId
if (sessionId) {
try {
socket.data = { ...socket.data, user: await getUserFromToken(token) }
const { user } = await getSessionAndUserFromSessionId(sessionId)
socket.data = { ...socket.data, user }
} catch (err) { }
}
next()

View File

@ -23,6 +23,7 @@ waspBuild/.wasp/build/server/src/entities/index.ts
waspBuild/.wasp/build/server/src/middleware/globalMiddleware.ts
waspBuild/.wasp/build/server/src/middleware/index.ts
waspBuild/.wasp/build/server/src/middleware/operations.ts
waspBuild/.wasp/build/server/src/polyfill.ts
waspBuild/.wasp/build/server/src/queries/types.ts
waspBuild/.wasp/build/server/src/routes/index.js
waspBuild/.wasp/build/server/src/routes/operations/index.js

View File

@ -167,6 +167,13 @@
],
"864c7492c27f6da1e67645fbc358dc803a168852bfd24f2c4dd13fccf6917b07"
],
[
[
"file",
"server/src/polyfill.ts"
],
"66d3dca514bdd01be402714d0dfe3836e76f612346dea57ee595ae4f3da915cf"
],
[
[
"file",
@ -193,7 +200,7 @@
"file",
"server/src/server.ts"
],
"1c5af223cf0309b341e87cf8b6afd58b0cb21217e64cd9ee498048136a9da5be"
"d0666b659cdc75db181ea2bbb50c4e157f0a7fbe00c4ff8fda0933b1a13e5a0e"
],
[
[
@ -326,14 +333,14 @@
"file",
"web-app/src/api.ts"
],
"93118387834981574ce1773d33275308e68ef8ca87408a35be8931c44a8889bf"
"850331885230117aa56317186c6d38f696fb1fbd0c56470ff7c6e4f3c1c43104"
],
[
[
"file",
"web-app/src/api/events.ts"
],
"7220e570cfb823028ad6c076cbcf033d217acfb88537bcac47020f1085757044"
"91ec1889f649b608ca81cab8f048538b9dcc70f49444430b1e5b572af2a4970a"
],
[
[

View File

@ -0,0 +1,7 @@
// This is a polyfill for Node.js 18 webcrypto API so Lucia can use it
// for random number generation.
import { webcrypto } from "node:crypto";
// @ts-ignore
globalThis.crypto = webcrypto as Crypto;

View File

@ -6,6 +6,8 @@ import config from './config.js'
import './polyfill.js'
const startServer = async () => {
const port = normalizePort(config.port)

View File

@ -8,59 +8,60 @@ const api = axios.create({
baseURL: config.apiUrl,
})
const WASP_APP_AUTH_TOKEN_NAME = 'authToken'
const WASP_APP_AUTH_SESSION_ID_NAME = 'sessionId'
let authToken = storage.get(WASP_APP_AUTH_TOKEN_NAME) as string | undefined
let waspAppAuthSessionId = storage.get(WASP_APP_AUTH_SESSION_ID_NAME) as string | undefined
export function setAuthToken(token: string): void {
authToken = token
storage.set(WASP_APP_AUTH_TOKEN_NAME, token)
apiEventsEmitter.emit('authToken.set')
export function setSessionId(sessionId: string): void {
waspAppAuthSessionId = sessionId
storage.set(WASP_APP_AUTH_SESSION_ID_NAME, sessionId)
apiEventsEmitter.emit('sessionId.set')
}
export function getAuthToken(): string | undefined {
return authToken
export function getSessionId(): string | undefined {
return waspAppAuthSessionId
}
export function clearAuthToken(): void {
authToken = undefined
storage.remove(WASP_APP_AUTH_TOKEN_NAME)
apiEventsEmitter.emit('authToken.clear')
export function clearSessionId(): void {
waspAppAuthSessionId = undefined
storage.remove(WASP_APP_AUTH_SESSION_ID_NAME)
apiEventsEmitter.emit('sessionId.clear')
}
export function removeLocalUserData(): void {
authToken = undefined
waspAppAuthSessionId = undefined
storage.clear()
apiEventsEmitter.emit('authToken.clear')
apiEventsEmitter.emit('sessionId.clear')
}
api.interceptors.request.use((request) => {
if (authToken) {
request.headers['Authorization'] = `Bearer ${authToken}`
const sessionId = getSessionId()
if (sessionId) {
request.headers['Authorization'] = `Bearer ${sessionId}`
}
return request
})
api.interceptors.response.use(undefined, (error) => {
if (error.response?.status === 401) {
clearAuthToken()
clearSessionId()
}
return Promise.reject(error)
})
// This handler will run on other tabs (not the active one calling API functions),
// and will ensure they know about auth token changes.
// and will ensure they know about auth session ID changes.
// Ref: https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event
// "Note: This won't work on the same page that is making the changes — it is really a way
// for other pages on the domain using the storage to sync any changes that are made."
window.addEventListener('storage', (event) => {
if (event.key === storage.getPrefixedKey(WASP_APP_AUTH_TOKEN_NAME)) {
if (event.key === storage.getPrefixedKey(WASP_APP_AUTH_SESSION_ID_NAME)) {
if (!!event.newValue) {
authToken = event.newValue
apiEventsEmitter.emit('authToken.set')
waspAppAuthSessionId = event.newValue
apiEventsEmitter.emit('sessionId.set')
} else {
authToken = undefined
apiEventsEmitter.emit('authToken.clear')
waspAppAuthSessionId = undefined
apiEventsEmitter.emit('sessionId.clear')
}
}
})

View File

@ -3,9 +3,9 @@ import mitt, { Emitter } from 'mitt';
type ApiEvents = {
// key: Event name
// type: Event payload type
'authToken.set': void;
'authToken.clear': void;
'sessionId.set': void;
'sessionId.clear': void;
};
// Used to allow API clients to register for auth token change events.
// Used to allow API clients to register for auth session ID change events.
export const apiEventsEmitter: Emitter<ApiEvents> = mitt<ApiEvents>();

View File

@ -24,6 +24,7 @@ waspCompile/.wasp/out/server/src/entities/index.ts
waspCompile/.wasp/out/server/src/middleware/globalMiddleware.ts
waspCompile/.wasp/out/server/src/middleware/index.ts
waspCompile/.wasp/out/server/src/middleware/operations.ts
waspCompile/.wasp/out/server/src/polyfill.ts
waspCompile/.wasp/out/server/src/queries/types.ts
waspCompile/.wasp/out/server/src/routes/index.js
waspCompile/.wasp/out/server/src/routes/operations/index.js

View File

@ -174,6 +174,13 @@
],
"864c7492c27f6da1e67645fbc358dc803a168852bfd24f2c4dd13fccf6917b07"
],
[
[
"file",
"server/src/polyfill.ts"
],
"66d3dca514bdd01be402714d0dfe3836e76f612346dea57ee595ae4f3da915cf"
],
[
[
"file",
@ -200,7 +207,7 @@
"file",
"server/src/server.ts"
],
"1c5af223cf0309b341e87cf8b6afd58b0cb21217e64cd9ee498048136a9da5be"
"d0666b659cdc75db181ea2bbb50c4e157f0a7fbe00c4ff8fda0933b1a13e5a0e"
],
[
[
@ -340,14 +347,14 @@
"file",
"web-app/src/api.ts"
],
"93118387834981574ce1773d33275308e68ef8ca87408a35be8931c44a8889bf"
"850331885230117aa56317186c6d38f696fb1fbd0c56470ff7c6e4f3c1c43104"
],
[
[
"file",
"web-app/src/api/events.ts"
],
"7220e570cfb823028ad6c076cbcf033d217acfb88537bcac47020f1085757044"
"91ec1889f649b608ca81cab8f048538b9dcc70f49444430b1e5b572af2a4970a"
],
[
[

View File

@ -0,0 +1,7 @@
// This is a polyfill for Node.js 18 webcrypto API so Lucia can use it
// for random number generation.
import { webcrypto } from "node:crypto";
// @ts-ignore
globalThis.crypto = webcrypto as Crypto;

View File

@ -6,6 +6,8 @@ import config from './config.js'
import './polyfill.js'
const startServer = async () => {
const port = normalizePort(config.port)

View File

@ -8,59 +8,60 @@ const api = axios.create({
baseURL: config.apiUrl,
})
const WASP_APP_AUTH_TOKEN_NAME = 'authToken'
const WASP_APP_AUTH_SESSION_ID_NAME = 'sessionId'
let authToken = storage.get(WASP_APP_AUTH_TOKEN_NAME) as string | undefined
let waspAppAuthSessionId = storage.get(WASP_APP_AUTH_SESSION_ID_NAME) as string | undefined
export function setAuthToken(token: string): void {
authToken = token
storage.set(WASP_APP_AUTH_TOKEN_NAME, token)
apiEventsEmitter.emit('authToken.set')
export function setSessionId(sessionId: string): void {
waspAppAuthSessionId = sessionId
storage.set(WASP_APP_AUTH_SESSION_ID_NAME, sessionId)
apiEventsEmitter.emit('sessionId.set')
}
export function getAuthToken(): string | undefined {
return authToken
export function getSessionId(): string | undefined {
return waspAppAuthSessionId
}
export function clearAuthToken(): void {
authToken = undefined
storage.remove(WASP_APP_AUTH_TOKEN_NAME)
apiEventsEmitter.emit('authToken.clear')
export function clearSessionId(): void {
waspAppAuthSessionId = undefined
storage.remove(WASP_APP_AUTH_SESSION_ID_NAME)
apiEventsEmitter.emit('sessionId.clear')
}
export function removeLocalUserData(): void {
authToken = undefined
waspAppAuthSessionId = undefined
storage.clear()
apiEventsEmitter.emit('authToken.clear')
apiEventsEmitter.emit('sessionId.clear')
}
api.interceptors.request.use((request) => {
if (authToken) {
request.headers['Authorization'] = `Bearer ${authToken}`
const sessionId = getSessionId()
if (sessionId) {
request.headers['Authorization'] = `Bearer ${sessionId}`
}
return request
})
api.interceptors.response.use(undefined, (error) => {
if (error.response?.status === 401) {
clearAuthToken()
clearSessionId()
}
return Promise.reject(error)
})
// This handler will run on other tabs (not the active one calling API functions),
// and will ensure they know about auth token changes.
// and will ensure they know about auth session ID changes.
// Ref: https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event
// "Note: This won't work on the same page that is making the changes — it is really a way
// for other pages on the domain using the storage to sync any changes that are made."
window.addEventListener('storage', (event) => {
if (event.key === storage.getPrefixedKey(WASP_APP_AUTH_TOKEN_NAME)) {
if (event.key === storage.getPrefixedKey(WASP_APP_AUTH_SESSION_ID_NAME)) {
if (!!event.newValue) {
authToken = event.newValue
apiEventsEmitter.emit('authToken.set')
waspAppAuthSessionId = event.newValue
apiEventsEmitter.emit('sessionId.set')
} else {
authToken = undefined
apiEventsEmitter.emit('authToken.clear')
waspAppAuthSessionId = undefined
apiEventsEmitter.emit('sessionId.clear')
}
}
})

View File

@ -3,9 +3,9 @@ import mitt, { Emitter } from 'mitt';
type ApiEvents = {
// key: Event name
// type: Event payload type
'authToken.set': void;
'authToken.clear': void;
'sessionId.set': void;
'sessionId.clear': void;
};
// Used to allow API clients to register for auth token change events.
// Used to allow API clients to register for auth session ID change events.
export const apiEventsEmitter: Emitter<ApiEvents> = mitt<ApiEvents>();

View File

@ -21,12 +21,16 @@ waspComplexTest/.wasp/out/server/src/actions/MySpecialAction.ts
waspComplexTest/.wasp/out/server/src/actions/types.ts
waspComplexTest/.wasp/out/server/src/apis/types.ts
waspComplexTest/.wasp/out/server/src/app.js
waspComplexTest/.wasp/out/server/src/auth/jwt.ts
waspComplexTest/.wasp/out/server/src/auth/lucia.ts
waspComplexTest/.wasp/out/server/src/auth/password.ts
waspComplexTest/.wasp/out/server/src/auth/providers/config/google.ts
waspComplexTest/.wasp/out/server/src/auth/providers/index.ts
waspComplexTest/.wasp/out/server/src/auth/providers/oauth/createRouter.ts
waspComplexTest/.wasp/out/server/src/auth/providers/oauth/init.ts
waspComplexTest/.wasp/out/server/src/auth/providers/oauth/types.ts
waspComplexTest/.wasp/out/server/src/auth/providers/types.ts
waspComplexTest/.wasp/out/server/src/auth/session.ts
waspComplexTest/.wasp/out/server/src/auth/user.ts
waspComplexTest/.wasp/out/server/src/auth/utils.ts
waspComplexTest/.wasp/out/server/src/auth/validation.ts
@ -60,10 +64,12 @@ waspComplexTest/.wasp/out/server/src/jobs/core/pgBoss/pgBossJob.ts
waspComplexTest/.wasp/out/server/src/middleware/globalMiddleware.ts
waspComplexTest/.wasp/out/server/src/middleware/index.ts
waspComplexTest/.wasp/out/server/src/middleware/operations.ts
waspComplexTest/.wasp/out/server/src/polyfill.ts
waspComplexTest/.wasp/out/server/src/queries/MySpecialQuery.ts
waspComplexTest/.wasp/out/server/src/queries/types.ts
waspComplexTest/.wasp/out/server/src/routes/apis/index.ts
waspComplexTest/.wasp/out/server/src/routes/auth/index.js
waspComplexTest/.wasp/out/server/src/routes/auth/logout.ts
waspComplexTest/.wasp/out/server/src/routes/auth/me.js
waspComplexTest/.wasp/out/server/src/routes/crud/index.ts
waspComplexTest/.wasp/out/server/src/routes/crud/tasks.ts

View File

@ -18,7 +18,7 @@
"file",
"db/schema.prisma"
],
"3e6bfc3dadfc9399a3fae3ea77cadb1aa3259efa0b8362ed77d6d5ee5013d393"
"5a41561cd6f853da2f4b9f64267f5d4e3b2865f94dfa92d9143acf2c1485519c"
],
[
[
@ -60,7 +60,7 @@
"file",
"server/package.json"
],
"53aef3360681af1936a5bb2a3f7fa1c7cb74f58f398942bb8bd979a9b78e9b16"
"831f2505c29201cd8e5076fda768423448a6cbba92410b0692fd8ac630aeb76b"
],
[
[
@ -88,7 +88,7 @@
"file",
"server/src/_types/index.ts"
],
"92027caebe484c7d412f97be9bc3c39257d10f9f2bc2e447e52c6855d5d30ffc"
"e7678be590a77799133e99d07a3326bbc6e627a5a219db8714a1b3d3b3304150"
],
[
[
@ -132,6 +132,27 @@
],
"86504ede1daeca35cc93f665ca8ac2fdf46ecaff02f6d3a7810a14d2bc71e16a"
],
[
[
"file",
"server/src/auth/jwt.ts"
],
"be637e3c0ac601c1b6edb4a5cc6a9300edfb7fb1568220672254524a6b395b89"
],
[
[
"file",
"server/src/auth/lucia.ts"
],
"dc5e75c3e3da677c8e98d1456964f1f9bce9105bf90f3c351379e54d3433523b"
],
[
[
"file",
"server/src/auth/password.ts"
],
"f9003f11bf1396bff69a98440543e6e131f50d5bc1269a8aae55d72615922705"
],
[
[
"file",
@ -151,7 +172,7 @@
"file",
"server/src/auth/providers/oauth/createRouter.ts"
],
"da122a8a244ddbd9b84bba72a7da2f32ffa41c6093614c6c0d59e113244d2bbc"
"90fe889802b86a96e82acfd9ace8b79d144bb33369236ac315b4dd67e31a9f69"
],
[
[
@ -174,19 +195,26 @@
],
"b647575a04eeb7824d95082a461d59763d034dc7d03a8fbcdd25143b6f8431b6"
],
[
[
"file",
"server/src/auth/session.ts"
],
"6edca533dbb38ddcba326fd0399dc6f5bc9b249327ed61c7c167d3a5a9d9a462"
],
[
[
"file",
"server/src/auth/user.ts"
],
"5787f3cdab4739781090f2950ba432dca812483ec23c6319ac3f876118324d15"
"74080241b9011ddce54ac2452b7c8a7064a4070ef1b04e420c730c62d787e077"
],
[
[
"file",
"server/src/auth/utils.ts"
],
"ba76300456ffdbd647923b27ff163df5e3efa016d7cd2a01af0b6a86dcd780a9"
"0b9bb32aac75246f649b01dca5545b2af3c4485a3196a17b91e2556be4891f9a"
],
[
[
@ -221,14 +249,14 @@
"file",
"server/src/core/auth.js"
],
"d708303af170e8159b93f0dda521b6f622c0f3add2d4f4f8f2fd88c0a4f7b79e"
"9eec6454bb4f583dec14b7fb7a3f2d802f536bad8cb15390b9800fe6dbccc596"
],
[
[
"file",
"server/src/crud/tasks.ts"
],
"2c4e1f94939adf825df14624940019889394a0e56cdea2855686d67e0c08458a"
"3dbc7ee4341bc00af125c02d4b771bc7e7fb5bc7063191d659e7243217e2761f"
],
[
[
@ -405,6 +433,13 @@
],
"64eeed927f46f6d6eba143023f25fb9ac4cd81d6b68c9a7067306ad28a3eda92"
],
[
[
"file",
"server/src/polyfill.ts"
],
"66d3dca514bdd01be402714d0dfe3836e76f612346dea57ee595ae4f3da915cf"
],
[
[
"file",
@ -431,14 +466,21 @@
"file",
"server/src/routes/auth/index.js"
],
"47fb3317c5707e0d7646bb1c0f6fa8d9c5c0f980ca2d643d226e63b49766cea3"
"9761e5af295928520748246d3be0ba4113655dbef3afb5c9123894627f3bee1a"
],
[
[
"file",
"server/src/routes/auth/logout.ts"
],
"1324b110888548ab535d78a4c7614c996152e948144a873d0262fa0aeb7ab4dc"
],
[
[
"file",
"server/src/routes/auth/me.js"
],
"705f77d8970a8367981c9a89601c6d5b12e998c23970ae1735b376dd0826ef10"
"9a9cb533bb94af63caf448f73a0d0fef8902c8f8d1af411bed2570a32da2fab9"
],
[
[
@ -487,7 +529,7 @@
"file",
"server/src/server.ts"
],
"93c05fac0fb2e30eeda90dbb374bfa5c7fcb860b4605da8ae2c6b6f913f95963"
"7963a3e625deb86593258b01dd87c94cf4e2103dbda3a6ee82e672911dee09bc"
],
[
[
@ -515,7 +557,7 @@
"file",
"server/src/utils.ts"
],
"f8834df362946064f32ef6a145769f83d10da712ad3daa226243fc590f89618f"
"7d29cb34de86e6a0689655e4165aed9fe5b0f82f54e4194f002f7d5823c7cb18"
],
[
[
@ -634,14 +676,14 @@
"file",
"web-app/src/api.ts"
],
"93118387834981574ce1773d33275308e68ef8ca87408a35be8931c44a8889bf"
"850331885230117aa56317186c6d38f696fb1fbd0c56470ff7c6e4f3c1c43104"
],
[
[
"file",
"web-app/src/api/events.ts"
],
"7220e570cfb823028ad6c076cbcf033d217acfb88537bcac47020f1085757044"
"91ec1889f649b608ca81cab8f048538b9dcc70f49444430b1e5b572af2a4970a"
],
[
[
@ -718,21 +760,21 @@
"file",
"web-app/src/auth/helpers/user.ts"
],
"e6bc091d8f8520db542f959846ecf528e8a070c5ce989151d00d2f45da4a58a6"
"e57cecd0a50b1515d6da8fcfb45f1e98ebefa4f58fe59d792100ffd40980fee9"
],
[
[
"file",
"web-app/src/auth/logout.ts"
],
"6717411aa38e54aa74a5034628510ee3ca0f2ea9d1692644ee4886c74d8657af"
"e04607e4676af958f580152432e873452a33a4276ecdb1a4c368efe2e3958a6c"
],
[
[
"file",
"web-app/src/auth/pages/OAuthCodeExchange.jsx"
],
"7dbcc288201aafbb50b5f5319a28283546c81d006fe61c2a8a3c5f55c6833fb2"
"1536a0abfd92944dca315e344758ba50f44a18e34c18a8c1c91046b1e4cc2a5e"
],
[
[
@ -746,7 +788,7 @@
"file",
"web-app/src/auth/types.ts"
],
"5ce8d0493c362093b0b2fc7b9df78a86688d3f40264ea8f29530f1d8fa67c4c6"
"26b1d53d6ea48d56421ee7050486eb9086367195534f21ca3371cd0685307d7c"
],
[
[
@ -760,7 +802,7 @@
"file",
"web-app/src/auth/user.ts"
],
"7113c286081f5597b822f5e576735d321cce38fcbd1a25db0d90e1163570068f"
"08cd2cf7dbb5aa5371efcfc1b16651c0c59d03d4ea36cd9f69bdaa9edc5ca68e"
],
[
[

View File

@ -38,6 +38,7 @@ model Auth {
userId Int? @unique
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
identities AuthIdentity[]
sessions Session[]
}
model AuthIdentity {
@ -49,3 +50,11 @@ model AuthIdentity {
@@id([providerName, providerUserId])
}
model Session {
id String @id @unique
expiresAt DateTime
userId String
auth Auth @relation(references: [id], fields: [userId], onDelete: Cascade)
@@index([userId])
}

View File

@ -1 +1 @@
3e6bfc3dadfc9399a3fae3ea77cadb1aa3259efa0b8362ed77d6d5ee5013d393
5a41561cd6f853da2f4b9f64267f5d4e3b2865f94dfa92d9143acf2c1485519c

View File

@ -1 +1 @@
{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.16.2"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"},{"name":"rate-limiter-flexible","version":"^2.4.1"},{"name":"superjson","version":"^1.12.2"},{"name":"passport","version":"0.6.0"},{"name":"passport-google-oauth20","version":"2.0.0"},{"name":"pg-boss","version":"^8.4.2"},{"name":"@sendgrid/mail","version":"^7.7.0"},{"name":"react-redux","version":"^7.1.3"},{"name":"redux","version":"^4.0.5"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.16.2"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.0.0"},{"name":"@tsconfig/node18","version":"latest"},{"name":"@types/uuid","version":"^9.0.0"},{"name":"@types/cors","version":"^2.8.5"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^1.4.0"},{"name":"react","version":"^18.2.0"},{"name":"react-dom","version":"^18.2.0"},{"name":"@tanstack/react-query","version":"^4.29.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.16.2"},{"name":"superjson","version":"^1.12.2"},{"name":"mitt","version":"3.0.0"},{"name":"react-hook-form","version":"^7.45.4"},{"name":"@stitches/react","version":"^1.2.8"},{"name":"react-redux","version":"^7.1.3"},{"name":"redux","version":"^4.0.5"}],"devDependencies":[{"name":"vite","version":"^4.3.9"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/react","version":"^18.0.37"},{"name":"@types/react-dom","version":"^18.0.11"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^2.0.0"},{"name":"vitest","version":"^0.29.3"},{"name":"@vitest/ui","version":"^0.29.3"},{"name":"jsdom","version":"^21.1.1"},{"name":"@testing-library/react","version":"^14.0.0"},{"name":"@testing-library/jest-dom","version":"^5.16.5"},{"name":"msw","version":"^1.1.0"}]}}
{"npmDepsForServer":{"dependencies":[{"name":"cookie-parser","version":"~1.4.6"},{"name":"cors","version":"^2.8.5"},{"name":"express","version":"~4.18.1"},{"name":"morgan","version":"~1.10.0"},{"name":"@prisma/client","version":"4.16.2"},{"name":"jsonwebtoken","version":"^8.5.1"},{"name":"secure-password","version":"^4.0.0"},{"name":"dotenv","version":"16.0.2"},{"name":"helmet","version":"^6.0.0"},{"name":"patch-package","version":"^6.4.7"},{"name":"uuid","version":"^9.0.0"},{"name":"lodash.merge","version":"^4.6.2"},{"name":"rate-limiter-flexible","version":"^2.4.1"},{"name":"superjson","version":"^1.12.2"},{"name":"lucia","version":"^3.0.0-beta.14"},{"name":"@lucia-auth/adapter-prisma","version":"^4.0.0-beta.9"},{"name":"passport","version":"0.6.0"},{"name":"passport-google-oauth20","version":"2.0.0"},{"name":"pg-boss","version":"^8.4.2"},{"name":"@sendgrid/mail","version":"^7.7.0"},{"name":"react-redux","version":"^7.1.3"},{"name":"redux","version":"^4.0.5"}],"devDependencies":[{"name":"nodemon","version":"^2.0.19"},{"name":"standard","version":"^17.0.0"},{"name":"prisma","version":"4.16.2"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/express","version":"^4.17.13"},{"name":"@types/express-serve-static-core","version":"^4.17.13"},{"name":"@types/node","version":"^18.0.0"},{"name":"@tsconfig/node18","version":"latest"},{"name":"@types/uuid","version":"^9.0.0"},{"name":"@types/cors","version":"^2.8.5"}]},"npmDepsForWebApp":{"dependencies":[{"name":"axios","version":"^1.4.0"},{"name":"react","version":"^18.2.0"},{"name":"react-dom","version":"^18.2.0"},{"name":"@tanstack/react-query","version":"^4.29.0"},{"name":"react-router-dom","version":"^5.3.3"},{"name":"@prisma/client","version":"4.16.2"},{"name":"superjson","version":"^1.12.2"},{"name":"mitt","version":"3.0.0"},{"name":"react-hook-form","version":"^7.45.4"},{"name":"@stitches/react","version":"^1.2.8"},{"name":"react-redux","version":"^7.1.3"},{"name":"redux","version":"^4.0.5"}],"devDependencies":[{"name":"vite","version":"^4.3.9"},{"name":"typescript","version":"^5.1.0"},{"name":"@types/react","version":"^18.0.37"},{"name":"@types/react-dom","version":"^18.0.11"},{"name":"@types/react-router-dom","version":"^5.3.3"},{"name":"@vitejs/plugin-react-swc","version":"^3.0.0"},{"name":"dotenv","version":"^16.0.3"},{"name":"@tsconfig/vite-react","version":"^2.0.0"},{"name":"vitest","version":"^0.29.3"},{"name":"@vitest/ui","version":"^0.29.3"},{"name":"jsdom","version":"^21.1.1"},{"name":"@testing-library/react","version":"^14.0.0"},{"name":"@testing-library/jest-dom","version":"^5.16.5"},{"name":"msw","version":"^1.1.0"}]}}

View File

@ -1,5 +1,6 @@
{
"dependencies": {
"@lucia-auth/adapter-prisma": "^4.0.0-beta.9",
"@prisma/client": "4.16.2",
"@sendgrid/mail": "^7.7.0",
"cookie-parser": "~1.4.6",
@ -9,6 +10,7 @@
"helmet": "^6.0.0",
"jsonwebtoken": "^8.5.1",
"lodash.merge": "^4.6.2",
"lucia": "^3.0.0-beta.14",
"morgan": "~1.10.0",
"passport": "0.6.0",
"passport-google-oauth20": "2.0.0",

View File

@ -82,18 +82,18 @@ type Context<Entities extends _Entity[]> = Expand<{
type ContextWithUser<Entities extends _Entity[]> = Expand<Context<Entities> & { user?: SanitizedUser }>
// TODO: This type must match the logic in core/auth.js (if we remove the
// TODO: This type must match the logic in auth/session.js (if we remove the
// password field from the object there, we must do the same here). Ideally,
// these two things would live in the same place:
// https://github.com/wasp-lang/wasp/issues/965
export type DeserializedAuthEntity = Expand<Omit<AuthIdentity, 'providerData'> & {
export type DeserializedAuthIdentity = Expand<Omit<AuthIdentity, 'providerData'> & {
providerData: Omit<EmailProviderData, 'password'> | Omit<UsernameProviderData, 'password'> | OAuthProviderData
}>
export type SanitizedUser = User & {
auth: Auth & {
identities: DeserializedAuthEntity[]
identities: DeserializedAuthIdentity[]
} | null
}

View File

@ -0,0 +1,12 @@
import jwt from 'jsonwebtoken'
import util from 'util'
import config from '../config.js'
const jwtSign = util.promisify(jwt.sign)
const jwtVerify = util.promisify(jwt.verify)
const JWT_SECRET = config.auth.jwtSecret
export const signData = (data, options) => jwtSign(data, JWT_SECRET, options)
export const verify = (token) => jwtVerify(token, JWT_SECRET)

View File

@ -0,0 +1,55 @@
import { Lucia } from "lucia";
import { PrismaAdapter } from "@lucia-auth/adapter-prisma";
import prisma from '../dbClient.js'
import config from '../config.js'
import { type User } from "../entities/index.js"
const prismaAdapter = new PrismaAdapter(
// Using `as any` here since Lucia's model types are not compatible with Prisma 4
// model types. This is a temporary workaround until we migrate to Prisma 5.
// This **works** in runtime, but Typescript complains about it.
prisma.session as any,
prisma.auth as any
);
/**
* We are using Lucia for session management.
*
* Some details:
* 1. We are using the Prisma adapter for Lucia.
* 2. We are not using cookies for session management. Instead, we are using
* the Authorization header to send the session token.
* 3. Our `Session` entity is connected to the `Auth` entity.
* 4. We are exposing the `userId` field from the `Auth` entity to
* make fetching the User easier.
*/
export const auth = new Lucia<{}, {
userId: User['id']
}>(prismaAdapter, {
// Since we are not using cookies, we don't need to set any cookie options.
// But in the future, if we decide to use cookies, we can set them here.
// sessionCookie: {
// name: "session",
// expires: true,
// attributes: {
// secure: !config.isDevelopment,
// sameSite: "lax",
// },
// },
getUserAttributes({ userId }) {
return {
userId,
};
},
});
declare module "lucia" {
interface Register {
Lucia: typeof auth;
DatabaseSessionAttributes: {};
DatabaseUserAttributes: {
userId: User['id']
};
}
}

View File

@ -0,0 +1,15 @@
import SecurePassword from 'secure-password'
const SP = new SecurePassword()
export const hashPassword = async (password: string): Promise<string> => {
const hashedPwdBuffer = await SP.hash(Buffer.from(password))
return hashedPwdBuffer.toString("base64")
}
export const verifyPassword = async (hashedPassword: string, password: string): Promise<void> => {
const result = await SP.verify(Buffer.from(password), Buffer.from(hashedPassword, "base64"))
if (result !== SecurePassword.VALID) {
throw new Error('Invalid password.')
}
}

View File

@ -12,11 +12,11 @@ import {
contextWithUserEntity,
createUser,
findAuthWithUserBy,
createAuthToken,
rethrowPossibleAuthError,
sanitizeAndSerializeProviderData,
} from "../../utils.js"
import { type User } from "../../../entities/index.js"
import { createSession } from "../../session.js"
import { type Auth } from "../../../entities/index.js"
import type { ProviderConfig, RequestWithWasp } from "../types.js"
import type { GetUserFieldsFn } from "./types.js"
import { handleRejection } from "../../../utils.js"
@ -52,9 +52,11 @@ export function createRouter(provider: ProviderConfig, initData: { passportStrat
const providerId = createProviderId(provider.id, providerProfile.id);
try {
const userId = await getUserIdFromProviderDetails(providerId, providerProfile, getUserFieldsFn)
const token = await createAuthToken(userId)
res.json({ token })
const authId = await getAuthIdFromProviderDetails(providerId, providerProfile, getUserFieldsFn)
const session = await createSession(authId)
return res.json({
sessionId: session.id,
})
} catch (e) {
rethrowPossibleAuthError(e)
}
@ -66,11 +68,11 @@ export function createRouter(provider: ProviderConfig, initData: { passportStrat
// We need a user id to create the auth token, so we either find an existing user
// or create a new one if none exists for this provider.
async function getUserIdFromProviderDetails(
async function getAuthIdFromProviderDetails(
providerId: ProviderId,
providerProfile: any,
getUserFieldsFn?: GetUserFieldsFn,
): Promise<User['id']> {
): Promise<Auth['id']> {
const existingAuthIdentity = await prisma.authIdentity.findUnique({
where: {
providerName_providerUserId: providerId,
@ -85,7 +87,7 @@ async function getUserIdFromProviderDetails(
})
if (existingAuthIdentity) {
return existingAuthIdentity.auth.user.id
return existingAuthIdentity.auth.id
} else {
const userFields = getUserFieldsFn
? await getUserFieldsFn(contextWithUserEntity, { profile: providerProfile })
@ -100,6 +102,6 @@ async function getUserIdFromProviderDetails(
userFields,
)
return user.id
return user.auth.id
}
}

View File

@ -0,0 +1,107 @@
import { Request as ExpressRequest } from "express";
import { type User } from "../entities/index.js"
import { type SanitizedUser } from '../_types/index.js'
import { auth } from "./lucia.js";
import type { Session } from "lucia";
import {
throwInvalidCredentialsError,
deserializeAndSanitizeProviderData,
} from "./utils.js";
import prisma from '../dbClient.js';
// Creates a new session for the `authId` in the database
export async function createSession(authId: string): Promise<Session> {
return auth.createSession(authId, {});
}
export async function getSessionAndUserFromBearerToken(req: ExpressRequest): Promise<{
user: SanitizedUser | null,
session: Session | null,
}> {
const authorizationHeader = req.headers["authorization"];
if (typeof authorizationHeader !== "string") {
return {
user: null,
session: null,
};
}
const sessionId = auth.readBearerToken(authorizationHeader);
if (!sessionId) {
return {
user: null,
session: null,
};
}
return getSessionAndUserFromSessionId(sessionId);
}
export async function getSessionAndUserFromSessionId(sessionId: string): Promise<{
user: SanitizedUser | null,
session: Session | null,
}> {
const { session, user: authEntity } = await auth.validateSession(sessionId);
if (!session || !authEntity) {
return {
user: null,
session: null,
};
}
return {
session,
user: await getUser(authEntity.userId)
}
}
async function getUser(userId: User['id']): Promise<SanitizedUser> {
const user = await prisma.user
.findUnique({
where: { id: userId },
include: {
auth: {
include: {
identities: true
}
}
}
})
if (!user) {
throwInvalidCredentialsError()
}
// TODO: This logic must match the type in _types/index.ts (if we remove the
// password field from the object here, we must to do the same there).
// Ideally, these two things would live in the same place:
// https://github.com/wasp-lang/wasp/issues/965
const deserializedIdentities = user.auth.identities.map((identity) => {
const deserializedProviderData = deserializeAndSanitizeProviderData(
identity.providerData,
{
shouldRemovePasswordField: true,
}
)
return {
...identity,
providerData: deserializedProviderData,
}
})
return {
...user,
auth: {
...user.auth,
identities: deserializedIdentities,
},
}
}
export function invalidateSession(sessionId: string): Promise<void> {
return auth.invalidateSession(sessionId);
}

View File

@ -2,7 +2,7 @@
// We have them duplicated in this file and in data/Generator/templates/react-app/src/auth/user.ts
// If you are changing the logic here, make sure to change it there as well.
import type { SanitizedUser as User, ProviderName, DeserializedAuthEntity } from '../_types/index'
import type { SanitizedUser as User, ProviderName, DeserializedAuthIdentity } from '../_types/index'
export function getEmail(user: User): string | null {
return findUserIdentity(user, "email")?.providerUserId ?? null;
@ -20,7 +20,7 @@ export function getFirstProviderUserId(user?: User): string | null {
return user.auth.identities[0].providerUserId ?? null;
}
export function findUserIdentity(user: User, providerName: ProviderName): DeserializedAuthEntity | undefined {
export function findUserIdentity(user: User, providerName: ProviderName): DeserializedAuthIdentity | undefined {
return user.auth.identities.find(
(identity) => identity.providerName === providerName
);

View File

@ -1,4 +1,5 @@
import { hashPassword, sign, verify } from '../core/auth.js'
import { hashPassword } from './password.js'
import { verify } from './jwt.js'
import AuthError from '../core/AuthError.js'
import HttpError from '../core/HttpError.js'
import prisma from '../dbClient.js'
@ -128,7 +129,9 @@ export async function createUser(
providerId: ProviderId,
serializedProviderData?: string,
userFields?: PossibleAdditionalSignupFields,
): Promise<User> {
): Promise<User & {
auth: Auth
}> {
return prisma.user.create({
data: {
// Using any here to prevent type errors when userFields are not
@ -145,7 +148,12 @@ export async function createUser(
},
}
},
}
},
// We need to include the Auth entity here because we need `authId`
// to be able to create a session.
include: {
auth: true,
},
})
}
@ -155,12 +163,6 @@ export async function deleteUserByAuthId(authId: string): Promise<{ count: numbe
} } })
}
export async function createAuthToken(
userId: User['id']
): Promise<string> {
return sign(userId);
}
export async function verifyToken<T = unknown>(token: string): Promise<T> {
return verify(token);
}
@ -288,3 +290,7 @@ function providerDataHasPasswordField(
): providerData is { hashedPassword: string } {
return 'hashedPassword' in providerData;
}
export function throwInvalidCredentialsError(message?: string): void {
throw new HttpError(401, 'Invalid credentials', { message })
}

View File

@ -1,23 +1,22 @@
import jwt from 'jsonwebtoken'
import SecurePassword from 'secure-password'
import util from 'util'
import { randomInt } from 'node:crypto'
import prisma from '../dbClient.js'
import { handleRejection } from '../utils.js'
import HttpError from '../core/HttpError.js'
import config from '../config.js'
import { deserializeAndSanitizeProviderData } from '../auth/utils.js'
const jwtSign = util.promisify(jwt.sign)
const jwtVerify = util.promisify(jwt.verify)
const JWT_SECRET = config.auth.jwtSecret
export const signData = (data, options) => jwtSign(data, JWT_SECRET, options)
export const sign = (id, options) => signData({ id }, options)
export const verify = (token) => jwtVerify(token, JWT_SECRET)
import { getSessionAndUserFromBearerToken } from '../auth/session.js'
import { throwInvalidCredentialsError } from '../auth/utils.js'
/**
* Auth middleware
*
* If the request includes an `Authorization` header it will try to authenticate the request,
* otherwise it will let the request through.
*
* - If authentication succeeds it sets `req.sessionId` and `req.user`
* - `req.user` is the user that made the request and it's used in
* all Wasp features that need to know the user that made the request.
* - `req.sessionId` is the ID of the session that authenticated the request.
* - If the request is not authenticated, it throws an error.
*/
const auth = handleRejection(async (req, res, next) => {
const authHeader = req.get('Authorization')
if (!authHeader) {
@ -27,69 +26,18 @@ const auth = handleRejection(async (req, res, next) => {
return next()
}
if (authHeader.startsWith('Bearer ')) {
const token = authHeader.substring(7, authHeader.length)
req.user = await getUserFromToken(token)
} else {
const { session, user } = await getSessionAndUserFromBearerToken(req);
if (!session || !user) {
throwInvalidCredentialsError()
}
req.sessionId = session.id
req.user = user
next()
})
export async function getUserFromToken(token) {
let userIdFromToken
try {
userIdFromToken = (await verify(token)).id
} catch (error) {
if (['TokenExpiredError', 'JsonWebTokenError', 'NotBeforeError'].includes(error.name)) {
throwInvalidCredentialsError()
} else {
throw error
}
}
const user = await prisma.user
.findUnique({
where: { id: userIdFromToken },
include: {
auth: {
include: {
identities: true
}
}
}
})
if (!user) {
throwInvalidCredentialsError()
}
// TODO: This logic must match the type in types/index.ts (if we remove the
// password field from the object here, we must to do the same there).
// Ideally, these two things would live in the same place:
// https://github.com/wasp-lang/wasp/issues/965
let sanitizedUser = { ...user }
sanitizedUser.auth.identities = sanitizedUser.auth.identities.map(identity => {
identity.providerData = deserializeAndSanitizeProviderData(identity.providerData, { shouldRemovePasswordField: true })
return identity
});
return sanitizedUser
}
const SP = new SecurePassword()
export const hashPassword = async (password) => {
const hashedPwdBuffer = await SP.hash(Buffer.from(password))
return hashedPwdBuffer.toString("base64")
}
export const verifyPassword = async (hashedPassword, password) => {
const result = await SP.verify(Buffer.from(password), Buffer.from(hashedPassword, "base64"))
if (result !== SecurePassword.VALID) {
throw new Error('Invalid password.')
}
}
// Generates an unused username that looks similar to "quick-purple-sheep-91231".
// It generates several options and ensures it picks one that is not currently in use.
export function generateAvailableDictionaryUsername() {
@ -138,8 +86,4 @@ async function findAvailableUsername(potentialUsernames) {
return availableUsernames[0]
}
export function throwInvalidCredentialsError(message) {
throw new HttpError(401, 'Invalid credentials', { message })
}
export default auth

View File

@ -10,7 +10,7 @@ import { Payload } from "../_types/serialization.js";
import type {
Task,
} from "../entities";
import { throwInvalidCredentialsError } from "../core/auth.js";
import { throwInvalidCredentialsError } from '../auth/utils.js'
type _WaspEntityTagged = _Task
type _WaspEntity = Task

View File

@ -0,0 +1,7 @@
// This is a polyfill for Node.js 18 webcrypto API so Lucia can use it
// for random number generation.
import { webcrypto } from "node:crypto";
// @ts-ignore
globalThis.crypto = webcrypto as Crypto;

View File

@ -2,12 +2,14 @@ import express from 'express'
import auth from '../../core/auth.js'
import me from './me.js'
import logout from './logout.js'
import providersRouter from '../../auth/providers/index.js'
const router = express.Router()
router.get('/me', auth, me)
router.post('/logout', auth, logout)
router.use('/', providersRouter)
export default router

View File

@ -0,0 +1,12 @@
import { handleRejection } from '../../utils.js'
import { throwInvalidCredentialsError } from '../../auth/utils.js'
import { invalidateSession } from '../../auth/session.js'
export default handleRejection(async (req, res) => {
if (req.sessionId) {
await invalidateSession(req.sessionId)
return res.json({ success: true })
} else {
throwInvalidCredentialsError()
}
})

View File

@ -1,6 +1,6 @@
import { serialize as superjsonSerialize } from 'superjson'
import { handleRejection } from '../../utils.js'
import { throwInvalidCredentialsError } from '../../core/auth.js'
import { throwInvalidCredentialsError } from '../../auth/utils.js'
export default handleRejection(async (req, res) => {
if (req.user) {

View File

@ -10,6 +10,8 @@ import { startPgBoss } from './jobs/core/pgBoss/pgBoss.js'
import './jobs/core/allJobs.js'
import './polyfill.js'
const startServer = async () => {
await startPgBoss()

View File

@ -8,7 +8,8 @@ import { fileURLToPath } from 'url'
import { type SanitizedUser } from './_types/index.js'
type RequestWithExtraFields = Request & {
user?: SanitizedUser
user?: SanitizedUser;
sessionId?: string;
}
/**

View File

@ -8,59 +8,60 @@ const api = axios.create({
baseURL: config.apiUrl,
})
const WASP_APP_AUTH_TOKEN_NAME = 'authToken'
const WASP_APP_AUTH_SESSION_ID_NAME = 'sessionId'
let authToken = storage.get(WASP_APP_AUTH_TOKEN_NAME) as string | undefined
let waspAppAuthSessionId = storage.get(WASP_APP_AUTH_SESSION_ID_NAME) as string | undefined
export function setAuthToken(token: string): void {
authToken = token
storage.set(WASP_APP_AUTH_TOKEN_NAME, token)
apiEventsEmitter.emit('authToken.set')
export function setSessionId(sessionId: string): void {
waspAppAuthSessionId = sessionId
storage.set(WASP_APP_AUTH_SESSION_ID_NAME, sessionId)
apiEventsEmitter.emit('sessionId.set')
}
export function getAuthToken(): string | undefined {
return authToken
export function getSessionId(): string | undefined {
return waspAppAuthSessionId
}
export function clearAuthToken(): void {
authToken = undefined
storage.remove(WASP_APP_AUTH_TOKEN_NAME)
apiEventsEmitter.emit('authToken.clear')
export function clearSessionId(): void {
waspAppAuthSessionId = undefined
storage.remove(WASP_APP_AUTH_SESSION_ID_NAME)
apiEventsEmitter.emit('sessionId.clear')
}
export function removeLocalUserData(): void {
authToken = undefined
waspAppAuthSessionId = undefined
storage.clear()
apiEventsEmitter.emit('authToken.clear')
apiEventsEmitter.emit('sessionId.clear')
}
api.interceptors.request.use((request) => {
if (authToken) {
request.headers['Authorization'] = `Bearer ${authToken}`
const sessionId = getSessionId()
if (sessionId) {
request.headers['Authorization'] = `Bearer ${sessionId}`
}
return request
})
api.interceptors.response.use(undefined, (error) => {
if (error.response?.status === 401) {
clearAuthToken()
clearSessionId()
}
return Promise.reject(error)
})
// This handler will run on other tabs (not the active one calling API functions),
// and will ensure they know about auth token changes.
// and will ensure they know about auth session ID changes.
// Ref: https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event
// "Note: This won't work on the same page that is making the changes — it is really a way
// for other pages on the domain using the storage to sync any changes that are made."
window.addEventListener('storage', (event) => {
if (event.key === storage.getPrefixedKey(WASP_APP_AUTH_TOKEN_NAME)) {
if (event.key === storage.getPrefixedKey(WASP_APP_AUTH_SESSION_ID_NAME)) {
if (!!event.newValue) {
authToken = event.newValue
apiEventsEmitter.emit('authToken.set')
waspAppAuthSessionId = event.newValue
apiEventsEmitter.emit('sessionId.set')
} else {
authToken = undefined
apiEventsEmitter.emit('authToken.clear')
waspAppAuthSessionId = undefined
apiEventsEmitter.emit('sessionId.clear')
}
}
})

View File

@ -3,9 +3,9 @@ import mitt, { Emitter } from 'mitt';
type ApiEvents = {
// key: Event name
// type: Event payload type
'authToken.set': void;
'authToken.clear': void;
'sessionId.set': void;
'sessionId.clear': void;
};
// Used to allow API clients to register for auth token change events.
// Used to allow API clients to register for auth session ID change events.
export const apiEventsEmitter: Emitter<ApiEvents> = mitt<ApiEvents>();

View File

@ -1,8 +1,8 @@
import { setAuthToken } from '../../api'
import { setSessionId } from '../../api'
import { invalidateAndRemoveQueries } from '../../operations/resources'
export async function initSession(token: string): Promise<void> {
setAuthToken(token)
export async function initSession(sessionId: string): Promise<void> {
setSessionId(sessionId)
// We need to invalidate queries after login in order to get the correct user
// data in the React components (using `useAuth`).
// Redirects after login won't work properly without this.

View File

@ -1,9 +1,17 @@
import { removeLocalUserData } from '../api'
import api, { removeLocalUserData } from '../api'
import { invalidateAndRemoveQueries } from '../operations/resources'
export default async function logout(): Promise<void> {
removeLocalUserData()
// TODO(filip): We are currently invalidating and removing all the queries, but
// we should remove only the non-public, user-dependent ones.
await invalidateAndRemoveQueries()
try {
await api.post('/auth/logout')
} finally {
// Even if the logout request fails, we still want to remove the local user data
// in case the logout failed because of a network error and the user walked away
// from the computer.
removeLocalUserData()
// TODO(filip): We are currently invalidating and removing all the queries, but
// we should remove only the non-public, user-dependent ones.
await invalidateAndRemoveQueries()
}
}

View File

@ -28,7 +28,7 @@ export default function OAuthCodeExchange({ pathToApiServerRouteHandlingOauthRed
// This helps us reuse one component for various methods (e.g., Google, Facebook, etc.).
const apiServerUrlHandlingOauthRedirect = constructOauthRedirectApiServerUrl(pathToApiServerRouteHandlingOauthRedirect)
exchangeCodeForJwtAndRedirect(history, apiServerUrlHandlingOauthRedirect)
exchangeCodeForSessionIdAndRedirect(history, apiServerUrlHandlingOauthRedirect)
return () => {
firstRender.current = false
}
@ -46,22 +46,22 @@ function constructOauthRedirectApiServerUrl(pathToApiServerRouteHandlingOauthRed
return `${config.apiUrl}${pathToApiServerRouteHandlingOauthRedirect}${queryParams}`
}
async function exchangeCodeForJwtAndRedirect(history, apiServerUrlHandlingOauthRedirect) {
const token = await exchangeCodeForJwt(apiServerUrlHandlingOauthRedirect)
async function exchangeCodeForSessionIdAndRedirect(history, apiServerUrlHandlingOauthRedirect) {
const sessionId = await exchangeCodeForSessionId(apiServerUrlHandlingOauthRedirect)
if (token !== null) {
await initSession(token)
if (sessionId !== null) {
await initSession(sessionId)
history.push('/')
} else {
console.error('Error obtaining JWT token')
console.error('Error obtaining session ID')
history.push('/login')
}
}
async function exchangeCodeForJwt(url) {
async function exchangeCodeForSessionId(url) {
try {
const response = await api.get(url)
return response?.data?.token || null
return response?.data?.sessionId || null
} catch (e) {
console.error(e)
return null

View File

@ -1,2 +1,2 @@
// todo(filip): turn into a proper import/path
export type { SanitizedUser as User, ProviderName, DeserializedAuthEntity } from '../../../server/src/_types/'
export type { SanitizedUser as User, ProviderName, DeserializedAuthIdentity } from '../../../server/src/_types/'

View File

@ -2,7 +2,7 @@
// We have them duplicated in this file and in data/Generator/templates/server/src/auth/user.ts
// If you are changing the logic here, make sure to change it there as well.
import type { User, ProviderName, DeserializedAuthEntity } from './types'
import type { User, ProviderName, DeserializedAuthIdentity } from './types'
export function getEmail(user: User): string | null {
return findUserIdentity(user, "email")?.providerUserId ?? null;
@ -20,7 +20,7 @@ export function getFirstProviderUserId(user?: User): string | null {
return user.auth.identities[0].providerUserId ?? null;
}
export function findUserIdentity(user: User, providerName: ProviderName): DeserializedAuthEntity | undefined {
export function findUserIdentity(user: User, providerName: ProviderName): DeserializedAuthIdentity | undefined {
return user.auth.identities.find(
(identity) => identity.providerName === providerName
);

View File

@ -31,6 +31,7 @@ waspJob/.wasp/out/server/src/jobs/core/pgBoss/pgBossJob.ts
waspJob/.wasp/out/server/src/middleware/globalMiddleware.ts
waspJob/.wasp/out/server/src/middleware/index.ts
waspJob/.wasp/out/server/src/middleware/operations.ts
waspJob/.wasp/out/server/src/polyfill.ts
waspJob/.wasp/out/server/src/queries/types.ts
waspJob/.wasp/out/server/src/routes/index.js
waspJob/.wasp/out/server/src/routes/operations/index.js

View File

@ -216,6 +216,13 @@
],
"864c7492c27f6da1e67645fbc358dc803a168852bfd24f2c4dd13fccf6917b07"
],
[
[
"file",
"server/src/polyfill.ts"
],
"66d3dca514bdd01be402714d0dfe3836e76f612346dea57ee595ae4f3da915cf"
],
[
[
"file",
@ -242,7 +249,7 @@
"file",
"server/src/server.ts"
],
"e28a2e72f8a0cedbfba8c6acfbc36b9ae35db9005aa26484d88ef7e7688efa5b"
"d26cf3913b82c3525fe2214e0d94606e36ccb306ca9b036979e003f1e1d44f4b"
],
[
[
@ -382,14 +389,14 @@
"file",
"web-app/src/api.ts"
],
"93118387834981574ce1773d33275308e68ef8ca87408a35be8931c44a8889bf"
"850331885230117aa56317186c6d38f696fb1fbd0c56470ff7c6e4f3c1c43104"
],
[
[
"file",
"web-app/src/api/events.ts"
],
"7220e570cfb823028ad6c076cbcf033d217acfb88537bcac47020f1085757044"
"91ec1889f649b608ca81cab8f048538b9dcc70f49444430b1e5b572af2a4970a"
],
[
[

View File

@ -0,0 +1,7 @@
// This is a polyfill for Node.js 18 webcrypto API so Lucia can use it
// for random number generation.
import { webcrypto } from "node:crypto";
// @ts-ignore
globalThis.crypto = webcrypto as Crypto;

View File

@ -8,6 +8,8 @@ import { startPgBoss } from './jobs/core/pgBoss/pgBoss.js'
import './jobs/core/allJobs.js'
import './polyfill.js'
const startServer = async () => {
await startPgBoss()

View File

@ -8,59 +8,60 @@ const api = axios.create({
baseURL: config.apiUrl,
})
const WASP_APP_AUTH_TOKEN_NAME = 'authToken'
const WASP_APP_AUTH_SESSION_ID_NAME = 'sessionId'
let authToken = storage.get(WASP_APP_AUTH_TOKEN_NAME) as string | undefined
let waspAppAuthSessionId = storage.get(WASP_APP_AUTH_SESSION_ID_NAME) as string | undefined
export function setAuthToken(token: string): void {
authToken = token
storage.set(WASP_APP_AUTH_TOKEN_NAME, token)
apiEventsEmitter.emit('authToken.set')
export function setSessionId(sessionId: string): void {
waspAppAuthSessionId = sessionId
storage.set(WASP_APP_AUTH_SESSION_ID_NAME, sessionId)
apiEventsEmitter.emit('sessionId.set')
}
export function getAuthToken(): string | undefined {
return authToken
export function getSessionId(): string | undefined {
return waspAppAuthSessionId
}
export function clearAuthToken(): void {
authToken = undefined
storage.remove(WASP_APP_AUTH_TOKEN_NAME)
apiEventsEmitter.emit('authToken.clear')
export function clearSessionId(): void {
waspAppAuthSessionId = undefined
storage.remove(WASP_APP_AUTH_SESSION_ID_NAME)
apiEventsEmitter.emit('sessionId.clear')
}
export function removeLocalUserData(): void {
authToken = undefined
waspAppAuthSessionId = undefined
storage.clear()
apiEventsEmitter.emit('authToken.clear')
apiEventsEmitter.emit('sessionId.clear')
}
api.interceptors.request.use((request) => {
if (authToken) {
request.headers['Authorization'] = `Bearer ${authToken}`
const sessionId = getSessionId()
if (sessionId) {
request.headers['Authorization'] = `Bearer ${sessionId}`
}
return request
})
api.interceptors.response.use(undefined, (error) => {
if (error.response?.status === 401) {
clearAuthToken()
clearSessionId()
}
return Promise.reject(error)
})
// This handler will run on other tabs (not the active one calling API functions),
// and will ensure they know about auth token changes.
// and will ensure they know about auth session ID changes.
// Ref: https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event
// "Note: This won't work on the same page that is making the changes — it is really a way
// for other pages on the domain using the storage to sync any changes that are made."
window.addEventListener('storage', (event) => {
if (event.key === storage.getPrefixedKey(WASP_APP_AUTH_TOKEN_NAME)) {
if (event.key === storage.getPrefixedKey(WASP_APP_AUTH_SESSION_ID_NAME)) {
if (!!event.newValue) {
authToken = event.newValue
apiEventsEmitter.emit('authToken.set')
waspAppAuthSessionId = event.newValue
apiEventsEmitter.emit('sessionId.set')
} else {
authToken = undefined
apiEventsEmitter.emit('authToken.clear')
waspAppAuthSessionId = undefined
apiEventsEmitter.emit('sessionId.clear')
}
}
})

View File

@ -3,9 +3,9 @@ import mitt, { Emitter } from 'mitt';
type ApiEvents = {
// key: Event name
// type: Event payload type
'authToken.set': void;
'authToken.clear': void;
'sessionId.set': void;
'sessionId.clear': void;
};
// Used to allow API clients to register for auth token change events.
// Used to allow API clients to register for auth session ID change events.
export const apiEventsEmitter: Emitter<ApiEvents> = mitt<ApiEvents>();

View File

@ -29,6 +29,7 @@ waspMigrate/.wasp/out/server/src/entities/index.ts
waspMigrate/.wasp/out/server/src/middleware/globalMiddleware.ts
waspMigrate/.wasp/out/server/src/middleware/index.ts
waspMigrate/.wasp/out/server/src/middleware/operations.ts
waspMigrate/.wasp/out/server/src/polyfill.ts
waspMigrate/.wasp/out/server/src/queries/types.ts
waspMigrate/.wasp/out/server/src/routes/index.js
waspMigrate/.wasp/out/server/src/routes/operations/index.js

View File

@ -174,6 +174,13 @@
],
"864c7492c27f6da1e67645fbc358dc803a168852bfd24f2c4dd13fccf6917b07"
],
[
[
"file",
"server/src/polyfill.ts"
],
"66d3dca514bdd01be402714d0dfe3836e76f612346dea57ee595ae4f3da915cf"
],
[
[
"file",
@ -200,7 +207,7 @@
"file",
"server/src/server.ts"
],
"1c5af223cf0309b341e87cf8b6afd58b0cb21217e64cd9ee498048136a9da5be"
"d0666b659cdc75db181ea2bbb50c4e157f0a7fbe00c4ff8fda0933b1a13e5a0e"
],
[
[
@ -340,14 +347,14 @@
"file",
"web-app/src/api.ts"
],
"93118387834981574ce1773d33275308e68ef8ca87408a35be8931c44a8889bf"
"850331885230117aa56317186c6d38f696fb1fbd0c56470ff7c6e4f3c1c43104"
],
[
[
"file",
"web-app/src/api/events.ts"
],
"7220e570cfb823028ad6c076cbcf033d217acfb88537bcac47020f1085757044"
"91ec1889f649b608ca81cab8f048538b9dcc70f49444430b1e5b572af2a4970a"
],
[
[

View File

@ -0,0 +1,7 @@
// This is a polyfill for Node.js 18 webcrypto API so Lucia can use it
// for random number generation.
import { webcrypto } from "node:crypto";
// @ts-ignore
globalThis.crypto = webcrypto as Crypto;

View File

@ -6,6 +6,8 @@ import config from './config.js'
import './polyfill.js'
const startServer = async () => {
const port = normalizePort(config.port)

View File

@ -8,59 +8,60 @@ const api = axios.create({
baseURL: config.apiUrl,
})
const WASP_APP_AUTH_TOKEN_NAME = 'authToken'
const WASP_APP_AUTH_SESSION_ID_NAME = 'sessionId'
let authToken = storage.get(WASP_APP_AUTH_TOKEN_NAME) as string | undefined
let waspAppAuthSessionId = storage.get(WASP_APP_AUTH_SESSION_ID_NAME) as string | undefined
export function setAuthToken(token: string): void {
authToken = token
storage.set(WASP_APP_AUTH_TOKEN_NAME, token)
apiEventsEmitter.emit('authToken.set')
export function setSessionId(sessionId: string): void {
waspAppAuthSessionId = sessionId
storage.set(WASP_APP_AUTH_SESSION_ID_NAME, sessionId)
apiEventsEmitter.emit('sessionId.set')
}
export function getAuthToken(): string | undefined {
return authToken
export function getSessionId(): string | undefined {
return waspAppAuthSessionId
}
export function clearAuthToken(): void {
authToken = undefined
storage.remove(WASP_APP_AUTH_TOKEN_NAME)
apiEventsEmitter.emit('authToken.clear')
export function clearSessionId(): void {
waspAppAuthSessionId = undefined
storage.remove(WASP_APP_AUTH_SESSION_ID_NAME)
apiEventsEmitter.emit('sessionId.clear')
}
export function removeLocalUserData(): void {
authToken = undefined
waspAppAuthSessionId = undefined
storage.clear()
apiEventsEmitter.emit('authToken.clear')
apiEventsEmitter.emit('sessionId.clear')
}
api.interceptors.request.use((request) => {
if (authToken) {
request.headers['Authorization'] = `Bearer ${authToken}`
const sessionId = getSessionId()
if (sessionId) {
request.headers['Authorization'] = `Bearer ${sessionId}`
}
return request
})
api.interceptors.response.use(undefined, (error) => {
if (error.response?.status === 401) {
clearAuthToken()
clearSessionId()
}
return Promise.reject(error)
})
// This handler will run on other tabs (not the active one calling API functions),
// and will ensure they know about auth token changes.
// and will ensure they know about auth session ID changes.
// Ref: https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event
// "Note: This won't work on the same page that is making the changes — it is really a way
// for other pages on the domain using the storage to sync any changes that are made."
window.addEventListener('storage', (event) => {
if (event.key === storage.getPrefixedKey(WASP_APP_AUTH_TOKEN_NAME)) {
if (event.key === storage.getPrefixedKey(WASP_APP_AUTH_SESSION_ID_NAME)) {
if (!!event.newValue) {
authToken = event.newValue
apiEventsEmitter.emit('authToken.set')
waspAppAuthSessionId = event.newValue
apiEventsEmitter.emit('sessionId.set')
} else {
authToken = undefined
apiEventsEmitter.emit('authToken.clear')
waspAppAuthSessionId = undefined
apiEventsEmitter.emit('sessionId.clear')
}
}
})

View File

@ -3,9 +3,9 @@ import mitt, { Emitter } from 'mitt';
type ApiEvents = {
// key: Event name
// type: Event payload type
'authToken.set': void;
'authToken.clear': void;
'sessionId.set': void;
'sessionId.clear': void;
};
// Used to allow API clients to register for auth token change events.
// Used to allow API clients to register for auth session ID change events.
export const apiEventsEmitter: Emitter<ApiEvents> = mitt<ApiEvents>();

View File

@ -1,24 +0,0 @@
-- CreateTable
CREATE TABLE "User" (
"id" SERIAL NOT NULL,
"username" TEXT NOT NULL,
"password" TEXT NOT NULL,
"address" TEXT,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Task" (
"id" SERIAL NOT NULL,
"title" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
CONSTRAINT "Task_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- AddForeignKey
ALTER TABLE "Task" ADD CONSTRAINT "Task_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -1,38 +0,0 @@
-- CreateTable
CREATE TABLE "Auth" (
"id" TEXT NOT NULL,
"email" TEXT,
"username" TEXT,
"password" TEXT,
"isEmailVerified" BOOLEAN NOT NULL DEFAULT false,
"emailVerificationSentAt" TIMESTAMP(3),
"passwordResetSentAt" TIMESTAMP(3),
"userId" INTEGER,
CONSTRAINT "Auth_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SocialAuthProvider" (
"id" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"authId" TEXT NOT NULL,
CONSTRAINT "SocialAuthProvider_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Auth_email_key" ON "Auth"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Auth_username_key" ON "Auth"("username");
-- CreateIndex
CREATE UNIQUE INDEX "Auth_userId_key" ON "Auth"("userId");
-- AddForeignKey
ALTER TABLE "Auth" ADD CONSTRAINT "Auth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SocialAuthProvider" ADD CONSTRAINT "SocialAuthProvider_authId_fkey" FOREIGN KEY ("authId") REFERENCES "Auth"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -1,24 +0,0 @@
/*
Warnings:
- You are about to drop the `SocialAuthProvider` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "SocialAuthProvider" DROP CONSTRAINT "SocialAuthProvider_authId_fkey";
-- DropTable
DROP TABLE "SocialAuthProvider";
-- CreateTable
CREATE TABLE "AuthIdentity" (
"providerName" TEXT NOT NULL,
"providerUserId" TEXT NOT NULL,
"providerData" TEXT NOT NULL DEFAULT '{}',
"authId" TEXT NOT NULL,
CONSTRAINT "AuthIdentity_pkey" PRIMARY KEY ("providerName","providerUserId")
);
-- AddForeignKey
ALTER TABLE "AuthIdentity" ADD CONSTRAINT "AuthIdentity_authId_fkey" FOREIGN KEY ("authId") REFERENCES "Auth"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -1,24 +0,0 @@
/*
Warnings:
- You are about to drop the column `email` on the `Auth` table. All the data in the column will be lost.
- You are about to drop the column `emailVerificationSentAt` on the `Auth` table. All the data in the column will be lost.
- You are about to drop the column `isEmailVerified` on the `Auth` table. All the data in the column will be lost.
- You are about to drop the column `password` on the `Auth` table. All the data in the column will be lost.
- You are about to drop the column `passwordResetSentAt` on the `Auth` table. All the data in the column will be lost.
- You are about to drop the column `username` on the `Auth` table. All the data in the column will be lost.
*/
-- DropIndex
DROP INDEX "Auth_email_key";
-- DropIndex
DROP INDEX "Auth_username_key";
-- AlterTable
ALTER TABLE "Auth" DROP COLUMN "email",
DROP COLUMN "emailVerificationSentAt",
DROP COLUMN "isEmailVerified",
DROP COLUMN "password",
DROP COLUMN "passwordResetSentAt",
DROP COLUMN "username";

View File

@ -1,13 +0,0 @@
/*
Warnings:
- You are about to drop the column `password` on the `User` table. All the data in the column will be lost.
- You are about to drop the column `username` on the `User` table. All the data in the column will be lost.
*/
-- DropIndex
DROP INDEX "User_username_key";
-- AlterTable
ALTER TABLE "User" DROP COLUMN "password",
DROP COLUMN "username";

View File

@ -0,0 +1,64 @@
-- CreateTable
CREATE TABLE "User" (
"id" SERIAL NOT NULL,
"address" TEXT,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Task" (
"id" SERIAL NOT NULL,
"title" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
CONSTRAINT "Task_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Auth" (
"id" TEXT NOT NULL,
"userId" INTEGER,
CONSTRAINT "Auth_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuthIdentity" (
"providerName" TEXT NOT NULL,
"providerUserId" TEXT NOT NULL,
"providerData" TEXT NOT NULL DEFAULT '{}',
"authId" TEXT NOT NULL,
CONSTRAINT "AuthIdentity_pkey" PRIMARY KEY ("providerName","providerUserId")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Auth_userId_key" ON "Auth"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Session_id_key" ON "Session"("id");
-- CreateIndex
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
-- AddForeignKey
ALTER TABLE "Task" ADD CONSTRAINT "Task_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Auth" ADD CONSTRAINT "Auth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuthIdentity" ADD CONSTRAINT "AuthIdentity_authId_fkey" FOREIGN KEY ("authId") REFERENCES "Auth"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "Auth"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -11,7 +11,6 @@ import { sanitizeAndSerializeProviderData } from '@wasp/auth/utils.js'
export const fields = defineAdditionalSignupFields({
address: (data) => {
console.log('Received data:', data)
const AddressSchema = z
.string({
required_error: 'Address is required',

View File

@ -0,0 +1,17 @@
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Session_id_key" ON "Session"("id");
-- CreateIndex
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "Auth"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -1,7 +1,7 @@
import { createTask } from './actions.js'
import type { DbSeedFn } from '@wasp/dbSeed/types.js'
import { PrismaClient } from '@prisma/client/index.js'
import { hashPassword } from '@wasp/core/auth.js'
import { hashPassword } from '@wasp/auth/password.js'
async function createUser(prismaClient: PrismaClient, data: any) {
const newUser = await prismaClient.user.create({

View File

@ -12,6 +12,7 @@ import Wasp.Generator.Monad
)
import qualified Wasp.Psl.Ast.Model as Psl.Model
import qualified Wasp.Psl.Ast.Model as Psl.Model.Field
import qualified Wasp.Util as Util
{--
@ -59,12 +60,22 @@ authIdentityEntityName = "AuthIdentity"
identitiesFieldOnAuthEntityName :: String
identitiesFieldOnAuthEntityName = "identities"
sessionEntityName :: String
sessionEntityName = "Session"
sessionsFieldOnAuthEntityName :: String
sessionsFieldOnAuthEntityName = "sessions"
authFieldOnSessionEntityName :: String
authFieldOnSessionEntityName = Util.toLowerFirst authEntityName
injectAuth :: [(String, AS.Entity.Entity)] -> (String, AS.Entity.Entity) -> Generator [(String, AS.Entity.Entity)]
injectAuth entities (userEntityName, userEntity) = do
authEntity <- makeAuthEntity userEntityIdField (userEntityName, userEntity)
authIdentityEntity <- makeAuthIdentityEntity
sessionEntity <- makeSessionEntity
let entitiesWithAuth = injectAuthIntoUserEntity userEntityName entities
return $ entitiesWithAuth ++ [authEntity, authIdentityEntity]
return $ entitiesWithAuth ++ [authEntity, authIdentityEntity, sessionEntity]
where
-- We validated the AppSpec so we are sure that the user entity has an id field.
userEntityIdField = fromJust $ AS.Entity.getIdField userEntity
@ -94,7 +105,7 @@ makeAuthIdentityEntity = case parsePslBody authIdentityPslBody of
makeAuthEntity :: Psl.Model.Field -> (String, AS.Entity.Entity) -> Generator (String, AS.Entity.Entity)
makeAuthEntity userEntityIdField (userEntityName, _) = case parsePslBody authEntityPslBody of
Left err -> logAndThrowGeneratorError $ GenericGeneratorError $ "Error while generating Auth entity: " ++ show err
Left err -> logAndThrowGeneratorError $ GenericGeneratorError $ "Error while generating " ++ authEntityName ++ " entity: " ++ show err
Right pslBody -> return (authEntityName, AS.Entity.makeEntity pslBody)
where
authEntityPslBody =
@ -104,6 +115,7 @@ makeAuthEntity userEntityIdField (userEntityName, _) = case parsePslBody authEnt
userId ${userEntityIdTypeText}? @unique
${userFieldOnAuthEntityNameText} ${userEntityNameText}? @relation(fields: [userId], references: [${userEntityIdFieldName}], onDelete: Cascade)
${identitiesFieldOnAuthEntityNameText} ${authIdentityEntityNameText}[]
${sessionsFieldOnAuthEntityNameText} ${sessionEntityNameText}[]
|]
authEntityIdTypeText = T.pack authEntityIdType
@ -111,10 +123,35 @@ makeAuthEntity userEntityIdField (userEntityName, _) = case parsePslBody authEnt
userFieldOnAuthEntityNameText = T.pack userFieldOnAuthEntityName
authIdentityEntityNameText = T.pack authIdentityEntityName
identitiesFieldOnAuthEntityNameText = T.pack identitiesFieldOnAuthEntityName
sessionsFieldOnAuthEntityNameText = T.pack sessionsFieldOnAuthEntityName
sessionEntityNameText = T.pack sessionEntityName
userEntityIdTypeText = T.pack $ show . Psl.Model.Field._type $ userEntityIdField
userEntityIdFieldName = T.pack $ Psl.Model.Field._name userEntityIdField
makeSessionEntity :: Generator (String, AS.Entity.Entity)
makeSessionEntity = case parsePslBody sessionEntityPslBody of
Left err -> logAndThrowGeneratorError $ GenericGeneratorError $ "Error while generating " ++ sessionEntityName ++ " entity: " ++ show err
Right pslBody -> return (sessionEntityName, AS.Entity.makeEntity pslBody)
where
sessionEntityPslBody =
T.unpack
[trimming|
id String @id @unique
expiresAt DateTime
// Needs to be called `userId` for Lucia to be able to create sessions
userId String
// The relation needs to be named as lowercased entity name, because that's what Lucia expects.
// If the entity is named `Foo`, the relation needs to be named `foo`.
${authFieldOnSessionEntityNameText} ${authEntityNameText} @relation(references: [id], fields: [userId], onDelete: Cascade)
@@index([userId])
|]
authEntityNameText = T.pack authEntityName
authFieldOnSessionEntityNameText = T.pack authFieldOnSessionEntityName
injectAuthIntoUserEntity :: String -> [(String, AS.Entity.Entity)] -> [(String, AS.Entity.Entity)]
injectAuthIntoUserEntity userEntityName entities =
let userEntity = fromJust $ lookup userEntityName entities

View File

@ -54,7 +54,7 @@ import Wasp.Generator.Monad (Generator)
import qualified Wasp.Generator.NpmDependencies as N
import Wasp.Generator.ServerGenerator.ApiRoutesG (genApis)
import Wasp.Generator.ServerGenerator.Auth.OAuthAuthG (depsRequiredByPassport)
import Wasp.Generator.ServerGenerator.AuthG (genAuth)
import Wasp.Generator.ServerGenerator.AuthG (depsRequiredByAuth, genAuth)
import qualified Wasp.Generator.ServerGenerator.Common as C
import Wasp.Generator.ServerGenerator.ConfigG (genConfigFile)
import Wasp.Generator.ServerGenerator.CrudG (genCrud)
@ -178,6 +178,7 @@ npmDepsForWasp spec =
("rate-limiter-flexible", "^2.4.1"),
("superjson", "^1.12.2")
]
++ depsRequiredByAuth spec
++ depsRequiredByPassport spec
++ depsRequiredByJobs spec
++ depsRequiredByEmail spec
@ -225,7 +226,8 @@ genSrcDir spec =
genFileCopy [relfile|core/HttpError.js|],
genDbClient spec,
genConfigFile spec,
genServerJs spec
genServerJs spec,
genFileCopy [relfile|polyfill.ts|]
]
<++> genServerUtils spec
<++> genRoutesDir spec

View File

@ -1,5 +1,6 @@
module Wasp.Generator.ServerGenerator.AuthG
( genAuth,
depsRequiredByAuth,
)
where
@ -19,6 +20,7 @@ import Wasp.AppSpec (AppSpec)
import qualified Wasp.AppSpec as AS
import qualified Wasp.AppSpec.App as AS.App
import qualified Wasp.AppSpec.App.Auth as AS.Auth
import qualified Wasp.AppSpec.App.Dependency as AS.Dependency
import Wasp.AppSpec.Valid (getApp)
import Wasp.Generator.AuthProviders (emailAuthProvider, gitHubAuthProvider, googleAuthProvider, localAuthProvider)
import qualified Wasp.Generator.AuthProviders.Email as EmailProvider
@ -41,12 +43,17 @@ genAuth spec = case maybeAuth of
sequence
[ genCoreAuth auth,
genAuthRoutesIndex auth,
genMeRoute auth,
genFileCopy [relfile|routes/auth/me.js|],
genFileCopy [relfile|routes/auth/logout.ts|],
genUtils auth,
genProvidersIndex auth,
genProvidersTypes auth,
genFileCopy [relfile|auth/validation.ts|],
genFileCopy [relfile|auth/user.ts|]
genFileCopy [relfile|auth/user.ts|],
genFileCopy [relfile|auth/password.ts|],
genFileCopy [relfile|auth/jwt.ts|],
genSessionTs auth,
genLuciaTs auth
]
<++> genIndexTs auth
<++> genLocalAuth auth
@ -69,9 +76,7 @@ genCoreAuth auth = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmpl
let userEntityName = AS.refName $ AS.Auth.userEntity auth
in object
[ "userEntityUpper" .= (userEntityName :: String),
"userEntityLower" .= (Util.toLowerFirst userEntityName :: String),
"authFieldOnUserEntityName" .= (DbAuth.authFieldOnUserEntityName :: String),
"identitiesFieldOnAuthEntityName" .= (DbAuth.identitiesFieldOnAuthEntityName :: String)
"userEntityLower" .= (Util.toLowerFirst userEntityName :: String)
]
genAuthRoutesIndex :: AS.Auth.Auth -> Generator FileDraft
@ -85,15 +90,6 @@ genAuthRoutesIndex auth = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Ju
authIndexFileInSrcDir :: Path' (Rel C.ServerSrcDir) File'
authIndexFileInSrcDir = [relfile|routes/auth/index.js|]
genMeRoute :: AS.Auth.Auth -> Generator FileDraft
genMeRoute auth = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData)
where
meRouteRelToSrc = [relfile|routes/auth/me.js|]
tmplFile = C.asTmplFile $ [reldir|src|] </> meRouteRelToSrc
dstFile = C.serverSrcDirInServerRootDir </> C.asServerSrcFile meRouteRelToSrc
tmplData = object ["userEntityLower" .= (Util.toLowerFirst (AS.refName $ AS.Auth.userEntity auth) :: String)]
genUtils :: AS.Auth.Auth -> Generator FileDraft
genUtils auth = return $ C.mkTmplFdWithDstAndData tmplFile dstFile (Just tmplData)
where
@ -158,3 +154,38 @@ genProvidersTypes auth = return $ C.mkTmplFdWithData [relfile|src/auth/providers
userEntityName = AS.refName $ AS.Auth.userEntity auth
tmplData = object ["userEntityUpper" .= (userEntityName :: String)]
genLuciaTs :: AS.Auth.Auth -> Generator FileDraft
genLuciaTs auth = return $ C.mkTmplFdWithData [relfile|src/auth/lucia.ts|] (Just tmplData)
where
tmplData =
object
[ "sessionEntityLower" .= (Util.toLowerFirst DbAuth.sessionEntityName :: String),
"authEntityLower" .= (Util.toLowerFirst DbAuth.authEntityName :: String),
"userEntityUpper" .= (userEntityName :: String)
]
userEntityName = AS.refName $ AS.Auth.userEntity auth
genSessionTs :: AS.Auth.Auth -> Generator FileDraft
genSessionTs auth = return $ C.mkTmplFdWithData [relfile|src/auth/session.ts|] (Just tmplData)
where
tmplData =
object
[ "userEntityUpper" .= userEntityName,
"userEntityLower" .= Util.toLowerFirst userEntityName,
"authFieldOnUserEntityName" .= DbAuth.authFieldOnUserEntityName,
"identitiesFieldOnAuthEntityName" .= DbAuth.identitiesFieldOnAuthEntityName
]
userEntityName = AS.refName $ AS.Auth.userEntity auth
depsRequiredByAuth :: AppSpec -> [AS.Dependency.Dependency]
depsRequiredByAuth spec = maybe [] (const authDeps) maybeAuth
where
maybeAuth = AS.App.auth $ snd $ getApp spec
authDeps =
AS.Dependency.fromList
[ ("lucia", "^3.0.0-beta.14"),
("@lucia-auth/adapter-prisma", "^4.0.0-beta.9")
]

View File

@ -26,10 +26,10 @@ genAuth spec =
Nothing -> return []
Just auth ->
sequence
[ genFileCopy [relfile|auth/logout.ts|],
genFileCopy [relfile|auth/helpers/user.ts|],
[ genFileCopy [relfile|auth/helpers/user.ts|],
genFileCopy [relfile|auth/types.ts|],
genFileCopy [relfile|auth/user.ts|],
genFileCopy [relfile|auth/logout.ts|],
genUseAuth auth,
genCreateAuthRequiredPage auth
]