Skip to content

Instantly share code, notes, and snippets.

@beerose
Created July 18, 2022 10:53
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save beerose/80f37b4b36cbd7ba2745701959e3cb8b to your computer and use it in GitHub Desktop.
Save beerose/80f37b4b36cbd7ba2745701959e3cb8b to your computer and use it in GitHub Desktop.
Blitz.js auth + Redis example
import IoRedis from 'ioredis';
import { setupBlitz } from '@blitzjs/next';
import { AuthServerPlugin, simpleRolesIsAuthorized, SessionModel, Session } from '@blitzjs/auth';
const dbs: Record<string, IoRedis.Redis | undefined> = {
default: undefined,
auth: undefined
};
export function getRedis(): IoRedis.Redis {
if (dbs.default) {
return dbs.default;
}
return (dbs.default = createRedis(0));
}
export function getAuthRedis(): IoRedis.Redis {
if (dbs.auth) {
return dbs.auth;
}
return (dbs.auth = createRedis(1));
}
export function createRedis(db: number) {
return new IoRedis({
port: 6379,
host: 'localhost',
keepAlive: 60,
keyPrefix: 'auth:',
db
});
}
const { gSSP, gSP, api } = setupBlitz({
plugins: [
AuthServerPlugin({
cookiePrefix: 'blitz-app-prefix',
isAuthorized: simpleRolesIsAuthorized,
storage: {
createSession: (session: SessionModel): Promise<SessionModel> => {
return new Promise<SessionModel>((resolve, reject) => {
getAuthRedis().set(`token:${session.handle}`, JSON.stringify(session), (err) => {
if (err) {
reject(err);
} else {
getAuthRedis().lpush(`device:${String(session.userId)}`, session.handle);
resolve(session);
}
});
});
},
deleteSession(handle: string): Promise<SessionModel> {
return new Promise<SessionModel>((resolve, reject) => {
getAuthRedis().get(`token:${handle}`).then((result) => {
if (result) {
const session = JSON.parse(result) as SessionModel;
const userId = (session.userId as unknown) as string;
getAuthRedis().lrem(userId, 0, handle).catch(reject);
}
getAuthRedis().del(handle, (err) => {
if (err) {
reject(err);
} else {
resolve({ handle });
}
});
});
});
},
getSession(handle: string): Promise<SessionModel | null> {
return new Promise<SessionModel | null>((resolve, reject) => {
getAuthRedis()
.get(`token:${handle}`)
.then((data: string | null) => {
if (data) {
resolve(JSON.parse(data));
} else {
resolve(null);
}
})
.catch(reject);
});
},
getSessions(userId: Session.PublicData['userId']): Promise<SessionModel[]> {
return new Promise<SessionModel[]>((resolve, reject) => {
getAuthRedis()
.lrange(`device:${String(userId)}`, 0, -1)
.then((result) => {
if (result) {
resolve(
result.map((handle) => {
return this.getSession(handle);
})
);
} else {
resolve([]);
}
})
.catch(reject);
});
},
updateSession(handle: string, session: Partial<SessionModel>): Promise<SessionModel> {
return new Promise<SessionModel>((resolve, reject) => {
getAuthRedis().get(`token:${handle}`).then((result) => {
if (result) {
const oldSession = JSON.parse(result) as SessionModel;
const merge = Object.assign(oldSession, session);
getAuthRedis().set(`token:${handle}`, JSON.stringify(merge)).catch(reject);
}
reject(new Error('cant update session'));
});
});
}
}
})
]
});
export { gSSP, gSP, api };
@lhocke
Copy link

lhocke commented Jul 28, 2022

very helpful gist and gave me a good starting point when enabling redis on my blitz app, only issue I ran into is in getSession. Redis stores expiresAt as a standard string so it needs to be converted to a JS Date Object or blitz throws an error when it attempts to determine if the session should have expired with isPast(date). Solved it similar to this code snippet

getSession(handle: string): Promise<SessionModel | null> {
	return new Promise<SessionModel | null>((resolve, reject) => {
		getAuthRedis()
			.get(`token:${handle}`)
			.then((data: string | null) => {
				if (data) {
                                        let session = JSON.parse(data)
                                        session.expiresAt = new Date(session.expiresAt)
					resolve(JSON.parse(session));
				} else {
					resolve(null);
				}
			})
		.catch(reject);
	});
},

@ShaharIlany
Copy link

Hey, I've just created a new version with Redis TTL Feature that I think will help manage applications on bigger scales.

import { SessionConfigMethods, SessionModel } from "@blitzjs/auth"
import { loadEnvConfig } from "@next/env"
import { differenceInSeconds } from "date-fns"
import IoRedis from "ioredis"

// Type validation filter for null / undefined values
const notEmpty = <TValue>(value: TValue | null | undefined): value is TValue => {
  if (value === null || value === undefined) return false
  // eslint-disable-next-line no-unused-vars
  const testDummy: TValue = value
  return true
}

const { REDIS_HOST, REDIS_PORT, REDIS_USER, REDIS_PASSWORD } = loadEnvConfig(
  process.cwd()
).combinedEnv

/**
 * Global is used here to ensure the connection
 * is cached across hot-reloads in development
 *
 * see https://github.com/vercel/next.js/discussions/12229#discussioncomment-83372
 */

let redisClient: Record<string, IoRedis> = global.redisClient
if (!redisClient) redisClient = global.redisClient = {}

const getRedisClient = () => {
  if (!redisClient.authClient) {
    redisClient.authClient = new IoRedis({
      port: Number(REDIS_PORT!),
      host: REDIS_HOST!,
      username: REDIS_USER!,
      password: REDIS_PASSWORD!,
    })
  }
  return redisClient.authClient
}

const getSession: SessionConfigMethods["getSession"] = async (handle: string) => {
  const client = getRedisClient()
  const session = await client.get(`session:${handle}`)
  if (!session) {
    return null
  }
  const parsedSession = JSON.parse(session) as SessionModel
  if (parsedSession.expiresAt) {
    parsedSession.expiresAt = new Date(parsedSession.expiresAt)
    const expiryInSeconds = differenceInSeconds(parsedSession.expiresAt!, new Date())
    await client.expire(`session:${parsedSession.handle}`, expiryInSeconds)
    await client.expire(`user:${parsedSession.userId!}`, expiryInSeconds)
  }
  return parsedSession
}

const getSessions: SessionConfigMethods["getSessions"] = async (userId: string) => {
  const client = getRedisClient()
  const sessionKeys = await client.lrange(`user:${userId}`, 0, -1)
  const sessions = (
    await Promise.all(
      sessionKeys.map(async (handle) => {
        const session = await getSession(handle)
        return session
      })
    )
  ).filter(notEmpty)

  return sessions
}

const createSession: SessionConfigMethods["createSession"] = async (session: SessionModel) => {
  const client = getRedisClient()
  const expiryInSeconds = differenceInSeconds(session.expiresAt!, new Date())
  await client.set(`session:${session.handle}`, JSON.stringify(session), "EX", expiryInSeconds)
  await client.lpush(`user:${session.userId}`, session.handle)
  await client.expire(`user:${session.userId}`, expiryInSeconds)
  return session
}

const deleteSession: SessionConfigMethods["deleteSession"] = async (handle: string) => {
  const client = getRedisClient()
  const session = await getSession(handle)
  if (session) {
    await client.lrem(`user:${session.userId}`, 0, handle)
    await client.del(`session:${session.handle}`)
    return session
  }
  return undefined
}

const updateSession: SessionConfigMethods["updateSession"] = async (
  handle: string,
  session: SessionModel
) => {
  const client = getRedisClient()
  const oldSession = await getSession(handle)
  if (oldSession) {
    const newSession = Object.assign(oldSession, session)
    const expiryInSeconds = differenceInSeconds(newSession.expiresAt!, new Date())
    await client.set(`session:${handle}`, JSON.stringify(newSession), "EX", expiryInSeconds)
    await client.expire(`user:${session.userId}`, expiryInSeconds)

    return newSession
  }
  return undefined
}

export default {
  getSession,
  getSessions,
  createSession,
  deleteSession,
  updateSession,
} as SessionConfigMethods

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment