Skip to content

Commit

Permalink
Apply latest auth changes to the prototype (#1646)
Browse files Browse the repository at this point in the history
  • Loading branch information
infomiho authored Jan 19, 2024
1 parent 667a31b commit a35040e
Show file tree
Hide file tree
Showing 20 changed files with 315 additions and 226 deletions.

This file was deleted.

3 changes: 0 additions & 3 deletions examples/todo-typescript/migrations/migration_lock.toml

This file was deleted.

6 changes: 3 additions & 3 deletions waspc/data/Generator/templates/sdk/wasp/api/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>();
47 changes: 24 additions & 23 deletions waspc/data/Generator/templates/sdk/wasp/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}
}
})
Expand Down
6 changes: 3 additions & 3 deletions waspc/data/Generator/templates/sdk/wasp/auth/helpers/user.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { setAuthToken } from 'wasp/api'
import { setSessionId } from 'wasp/api'
import { invalidateAndRemoveQueries } from 'wasp/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.
Expand Down
12 changes: 12 additions & 0 deletions waspc/data/Generator/templates/sdk/wasp/auth/jwt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import jwt from 'jsonwebtoken'
import util from 'util'

import config from 'wasp/core/config'

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)
2 changes: 1 addition & 1 deletion waspc/data/Generator/templates/sdk/wasp/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export default async function login(username: string, password: string): Promise
const args = { username, password }
const response = await api.post('/auth/username/login', args)

await initSession(response.data.token)
await initSession(response.data.sessionId)
} catch (error) {
handleApiError(error)
}
Expand Down
18 changes: 13 additions & 5 deletions waspc/data/Generator/templates/sdk/wasp/auth/logout.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import { removeLocalUserData } from 'wasp/api'
import api, { removeLocalUserData } from 'wasp/api'
import { invalidateAndRemoveQueries } from 'wasp/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()
}
}
55 changes: 55 additions & 0 deletions waspc/data/Generator/templates/sdk/wasp/auth/lucia.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Lucia } from "lucia";
import { PrismaAdapter } from "@lucia-auth/adapter-prisma";
import prisma from '../server/dbClient.js'
import config from 'wasp/core/config'
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']
};
}
}
15 changes: 15 additions & 0 deletions waspc/data/Generator/templates/sdk/wasp/auth/password.ts
Original file line number Diff line number Diff line change
@@ -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.')
}
}
14 changes: 8 additions & 6 deletions waspc/data/Generator/templates/sdk/wasp/auth/providers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,18 @@ export type InitData = {

export type RequestWithWasp = Request & { wasp?: { [key: string]: any } }

export type PossibleAdditionalSignupFields = Expand<Partial<UserEntityCreateInput>>
export type PossibleUserFields = Expand<Partial<UserEntityCreateInput>>

export function defineAdditionalSignupFields(config: {
[key in keyof PossibleAdditionalSignupFields]: FieldGetter<
PossibleAdditionalSignupFields[key]
export type UserSignupFields = {
[key in keyof PossibleUserFields]: FieldGetter<
PossibleUserFields[key]
>
}) {
return config
}

type FieldGetter<T> = (
data: { [key: string]: unknown }
) => Promise<T | undefined> | T | undefined

export function defineUserSignupFields(fields: UserSignupFields) {
return fields
}
107 changes: 107 additions & 0 deletions waspc/data/Generator/templates/sdk/wasp/auth/session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { Request as ExpressRequest } from "express";

import { type User } from "../entities/index.js"
import { type SanitizedUser } from '../server/_types/index.js'

import { auth } from "./lucia.js";
import type { Session } from "lucia";
import {
throwInvalidCredentialsError,
deserializeAndSanitizeProviderData,
} from "./utils.js";

import prisma from '../server/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);
}
2 changes: 1 addition & 1 deletion waspc/data/Generator/templates/sdk/wasp/auth/types.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
// todo(filip): turn into a proper import/path
export type { SanitizedUser as User, ProviderName, DeserializedAuthEntity } from 'wasp/server/_types/'
export type { SanitizedUser as User, ProviderName, DeserializedAuthIdentity } from 'wasp/server/_types/'
4 changes: 2 additions & 2 deletions waspc/data/Generator/templates/sdk/wasp/auth/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
);
Expand Down
Loading

0 comments on commit a35040e

Please sign in to comment.