Skip to content

Instantly share code, notes, and snippets.

@balazsorban44
Last active October 16, 2024 05:17
Show Gist options
  • Save balazsorban44/86a98410c0365c4958d0e86114c3469d to your computer and use it in GitHub Desktop.
Save balazsorban44/86a98410c0365c4958d0e86114c3469d to your computer and use it in GitHub Desktop.
import NextAuth from "next-auth"
import Providers from "next-auth/providers"
import { addSeconds } from "date-fns"
import type { User } from "hooks/useUser"
import log from "utils/server-logger"
import sessionsDB, { InactiveSessionReason } from "lib/session-db"
import jwtDecode from "jwt-decode"
/** @see https://docs.microsoft.com/en-us/azure/active-directory/develop/id-tokens#payload-claims */
export interface IDToken {
nbf: number
exp: number
iss: string
aud: string
/** "Issued At" indicates when the authentication for this token occurred. */
iat: number
at_hash: string
/** "Session ID" generated by the IdP */
sid: string
/** "Subject", or User ID. */
sub: string
auth_time: number
idp: string
amr: string[]
}
export interface Profile extends IDToken {
id: string
email: string
name: string
concurrency: number
roles: string[]
idToken: string
}
export interface Account {
provider: "identity-server4"
type: "oauth"
id: string
refreshToken: string
accessToken: string
accessTokenExpires: number | null
idToken: string
expires_in: number
}
export interface JWT {
error?:
| "RefreshAccessTokenError"
| "ConcurrencyError"
| "ExpiredError"
| "EmptySessionDBError"
accessToken: string | null
refreshToken: string
accessTokenExpires: number
user: User
id: string
sessionId: string
idToken: string
}
export type Session = Omit<
JWT,
"idToken" | "sessionId" | "refreshToken" | "id" | "accessTokenExpires"
>
interface RefreshTokenResponse {
id_token: string
access_token: string
expires_in: number
token_type: string
refresh_token: string
}
export default NextAuth({
callbacks: {
//@ts-ignore
async signIn(user: User) {
const numberOfActiveSessions = await sessionsDB.getNumberOfActiveSessions(
user.id
)
if (numberOfActiveSessions + 1 > user.concurrency) {
await sessionsDB.removeOldestActiveSession(user.id)
}
return true
},
//@ts-ignore
async jwt(token: JWT, user: User, account: Account) {
// Signin in
if (account && user) {
const accessTokenExpires = Date.now() + account.expires_in,
//REVIEW: The token is not validated, do we need to?
// maybe jwt.decode() from next-auth/jwt?
const { sid } = jwtDecode(account.idToken) as IDToken
log.debug(sid)
const sessionId = await sessionsDB.addSession({
userId: user.id,
sessionId: sid,
concurrency: user.concurrency,
})
return {
accessToken: account.accessToken,
accessTokenExpires,
id: user.id,
refreshToken: account.refreshToken,
sessionId,
idToken: account.idToken,
user,
}
}
// the session is invalid, annul the access token
const expiredReason = await sessionsDB.isActiveSession(
token.id,
token.sessionId
)
if (expiredReason) {
const errors: Record<InactiveSessionReason, JWT["error"]> = {
concurrency: "ConcurrencyError",
expired: "ExpiredError",
restored: "EmptySessionDBError",
}
return {
...token,
error: errors[expiredReason],
accessToken: "",
accessTokenExpires: Date.now(),
}
}
// Subsequent use of JWT, the user has been logged in before
// access token has not expired yet
if (Date.now() < token.accessTokenExpires) {
return token
}
// access token has expired, check if the session is still valid.
return refreshAccessToken(token)
},
//@ts-ignore
async session(session: Session, token: JWT) {
if (token) {
session.user = token.user
session.accessToken = token.accessToken
session.error = token.error
}
return session
},
async redirect(url, baseUrl) {
if (url.startsWith(baseUrl)) {
return url
}
// If the redirect url is not absolute, prepend with base URL
return new URL(url, baseUrl).toString()
},
},
events: {
async signOut(token: JWT) {
if (token) {
await sessionsDB.removeSession(token.id, token.sessionId)
}
},
async error(error) {
log.error(
{ ...error, message: error.message ?? "An unknown error occurred" },
"NextAuthError: {message}"
)
},
},
jwt: {
encryption: true,
secret: process.env.JWT_SECRET,
},
session: {
maxAge: 60 * 60 * 2, // 2 hours
},
providers: [
Providers.IdentityServer4({
clientId: process.env.CLIENT_SECRET,
clientSecret: process.env.CLIENT_SECRET,
domain: process.env.NEXT_PUBLIC_IDS.replace("https://", ""),
id: "identity-server4",
name: "IdentityServer4",
scope: "openid email offline_access ...",
// @ts-ignore
profile(profile: Profile): User {
return {
id: profile.sub,
email: profile.email,
name: profile.name,
concurrency: profile.concurrency ?? 3,
roles: profile.roles,
}
},
protection: "pkce",
}),
],
secret: process.env.SESSION_COOKIE_SECRET,
})
/**
* Takes a `JWT`, and returns a new `JWT` with updated
* `accessToken` and `accessTokenExpires`
*/
async function refreshAccessToken(token: JWT): Promise<JWT> {
try {
const url = `${process.env.NEXT_PUBLIC_IDP_DOMAIN}/connect/token`
const response = await fetch(url, {
body: new URLSearchParams({
client_id: process.env.CLIENT_ID,
client_secret: process.env.CLIENT_SECRET,
grant_type: "refresh_token",
refresh_token: token.refreshToken,
}),
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
method: "POST",
})
const refreshToken: RefreshTokenResponse = await response.json()
if (!response.ok) {
throw { refreshToken }
}
return {
...token,
accessToken: refreshToken.access_token,
// Give a 10 sec buffer
accessTokenExpires: Date.now() + refreshToken.expires_in * 1000 - 10000,
refreshToken: refreshToken.refresh_token,
}
} catch (error) {
log.error(error, "RefreshAccessTokenError: Could not get new access_token")
return {
...token,
accessToken: null,
error: "RefreshAccessTokenError",
}
}
}
import log from "utils/server-logger"
/** A session stored in a SessionDB instance */
export type Session = {
/** UNIX timestamp of creation of this session. */
createdAt: number
/** Set to `true` if you want to indicate that this session is not valid anymore */
expired?: boolean
}
export type InactiveSessionReason = "expired" | "concurrency" | "restored"
export type UserSessions = Record<string, Session>
interface AddSessionParams {
userId: string
concurrency: number
sessionId: string
}
interface SessionDB {
database: Record<string, UserSessions>
createdAt: Date
addSession(addSessionParams: AddSessionParams): Promise<string>
removeOldestActiveSession(userId: string): Promise<string | undefined>
isActiveSession(
userId: string,
sessionId: string
): Promise<InactiveSessionReason | null>
getNumberOfActiveSessions(userId: string): Promise<number>
removeSession(userId: string, sessionId: string): Promise<boolean>
getUserIdFromSessionId(sessionId: string): Promise<string | undefined>
expireSession(userId: string, sessionId: string): Promise<void>
}
export const sessionDB: SessionDB = {
database: {},
createdAt: new Date(),
async addSession({ userId, concurrency, sessionId }) {
if (!this.database[userId]) {
this.database[userId] = {}
}
const noActiveUserSessions = await this.getNumberOfActiveSessions(userId)
if (noActiveUserSessions >= concurrency) {
const oldestSessionId = await this.removeOldestActiveSession(userId)
log.debug(
{
userId,
concurrency,
db: this.database,
createdAt: this.createdAt,
oldestSessionId,
},
`User {userId} exceeded max no. of concurrent logins ({concurrency}), removing oldest session {oldestSessionId}`
)
}
log.debug(
{ userId, concurrency, sessionId },
"New session {sessionId} added to database for user {userId}"
)
this.database[userId][sessionId] = { createdAt: Date.now() }
return sessionId
},
async removeOldestActiveSession(userId) {
let oldestSessionId
const user = this.database[userId] ?? {}
// We only want to remove the oldest session that is not marked as expired
for (const [sid, session] of Object.entries(user)) {
if (!session.expired) {
oldestSessionId = sid
this.removeSession(userId, sid)
break
}
}
log.debug(
{ userId, oldestSession: oldestSessionId },
"Oldest session {oldestSession} is removed for user {userId}"
)
return oldestSessionId
},
async isActiveSession(userId, sessionId) {
// If the user is not found in the DB, that means the DB has been emptied by a re-deploy
if (!this.database[userId]) {
log.debug(`Database empty, please require re-login from users`)
return "restored"
}
const session = this.database[userId][sessionId]
if (session?.expired) {
return "expired"
}
if (session) {
log.debug(
{ userId, sessionId, db: this.database, createdAt: this.createdAt },
`User {userId}'s sessionId {sessionId} is in the database`
)
return null
}
log.debug(
{ userId, sessionId, db: this.database, createdAt: this.createdAt },
`User {userId}'s sessionId {sessionId} is NOT in the database`
)
return "concurrency"
},
async getNumberOfActiveSessions(userId) {
const user = this.database[userId] ?? {}
const noActiveUserSessions = Object.values(user).filter(
(session) => !session.expired
).length
Object.keys(this.database[userId] ?? {})?.length ?? 0
log.debug(
{ userId, noUserSessions: noActiveUserSessions },
`User {userId} has {noUserSessions} active session(s)`
)
return noActiveUserSessions
},
async removeSession(userId, sessionId) {
delete this.database[userId]?.[sessionId]
log.debug(
{ userId, sessionId },
"Session {sessionId} is removed from the database for user {userId}"
)
return true
},
async getUserIdFromSessionId(sessionId) {
for (const [userId, userSessions] of Object.entries(this.database)) {
if (Object.keys(userSessions).includes(sessionId)) {
return userId
}
}
},
async expireSession(userId, sessionId) {
if (this.database[userId]?.[sessionId]) {
log.debug({ userId, sessionId }, "Found session, marking as expired")
this.database[userId][sessionId].expired = true
}
},
}
import * as React from "react"
import { Session } from "pages/api/auth/[...nextauth]"
import { signIn as nextAuthSignIn, signOut, useSession } from "next-auth/client"
import { rewrites } from "utils/routes"
export interface User {
id: string
email: string
name: string
concurrency: number
roles?: string[]
}
interface SignInParams {
redirectTo?: string
forceLogin?: boolean
params?: Record<string, any>
}
function signIn({ redirectTo, forceLogin = true, params }: SignInParams = {}) {
const authorizationParams = forceLogin
? { forceLogin: true, prompt: "login" }
: undefined
nextAuthSignIn(
"identity-server4",
{ callbackUrl: redirectTo },
//@ts-ignore
{ ...authorizationParams, ...params }
)
}
function authorized(roles?: User["roles"]) {
return function authorized(acceptedRoles?: string[]) {
// 1. Indicates public data, anyone is authorized
if (!acceptedRoles?.length || acceptedRoles.includes("Everyone")) {
return true
}
// 2. User has no roles, not authorized for anything
if (!roles) {
return false
}
return roles.some((role) => {
// 3. User is admin, they rule 😎
if (role === "Admin") {
return true
}
// 4. Let's see...
return acceptedRoles.includes(role)
})
}
}
export interface UseUser {
/** Information about the logged in user. */
user?: User
/**
* Takes a list of properties that are checked against user roles.
* Users with the role `"Admin"` are always authorized implicitly.
* @example
* ```js
* // Given that user has the role `"Subscriber"`
* authorized(["Subscriber"]) // returns `true`
* // Given that user has the role `"Admin"`
* authorized([]) // returns `true`
* authorized() // returns `true`
* authorized(["Everyone"]) // returns `true`
* ```
*/
authorized: ReturnType<typeof authorized>
/** Indicates if user is logged in or not, after the initial loading. */
authenticated: boolean
signIn: typeof signIn
loading: boolean
}
let errorHandled = false
export default function useUser(): UseUser {
const [session, loading] = (useSession() as unknown) as [
Session | undefined,
boolean
]
const error = session?.error
const user = session?.user
const userConcurrency = session?.user.concurrency
React.useEffect(() => {
if (loading || errorHandled) {
return
}
errorHandled = true
switch (error) {
case "ConcurrencyError": {
const path = rewrites["/concurrent-logins"]
// Redirect to page that explains the reason
signOut({ callbackUrl: `${path}?allowed=${userConcurrency}` })
break
}
case "ExpiredError":
// The user has been logged out through a front-channel logout, log them out of the app as well
signOut({
callbackUrl: rewrites["/logged-out"],
})
break
case "EmptySessionDBError": // The server has been restarted, we try to log the user in, silently, if possible.
case "RefreshAccessTokenError": // The refresh token did not work, try logging the user in, silently, if possible.
signIn({ forceLogin: false })
break
default:
break
}
}, [loading, userConcurrency, error])
return {
authorized: authorized(user?.roles),
signIn,
user,
loading,
authenticated: !loading && !!session?.accessToken,
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment