Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Apply latest auth changes to the prototype #1646

Merged
merged 5 commits into from
Jan 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

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
Loading