Last active
October 16, 2024 05:17
-
-
Save balazsorban44/86a98410c0365c4958d0e86114c3469d to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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", | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} | |
}, | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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