213 lines
6.6 KiB
TypeScript
213 lines
6.6 KiB
TypeScript
/* eslint-disable unicorn/no-null,max-params */
|
|
import { createHash, randomBytes } from "node:crypto";
|
|
import omit from "lodash/omit.js";
|
|
import { IMetamigoRepositories, idKeysOf } from "../records/index.js";
|
|
import type { UnsavedAccount } from "../records/account.js";
|
|
import type { UserId, UnsavedUser, SavedUser } from "../records/user.js";
|
|
import type { UnsavedSession, SavedSession } from "../records/session.js";
|
|
import {
|
|
AdapterAccount,
|
|
AdapterSession,
|
|
AdapterUser,
|
|
} from "next-auth/adapters.js";
|
|
import { ReadableStreamDefaultController } from "stream/web";
|
|
|
|
// Sessions expire after 30 days of being idle
|
|
export const defaultSessionMaxAge = 30 * 24 * 60 * 60 * 1000;
|
|
// Sessions updated only if session is greater than this value (0 = always)
|
|
export const defaulteSessionUpdateAge = 24 * 60 * 60 * 1000;
|
|
|
|
const getCompoundId = (providerId: any, providerAccountId: any) =>
|
|
createHash("sha256")
|
|
.update(`${providerId}:${providerAccountId}`)
|
|
.digest("hex");
|
|
|
|
const randomToken = () => randomBytes(32).toString("hex");
|
|
|
|
export class NextAuthAdapter<TRepositories extends IMetamigoRepositories> {
|
|
constructor(
|
|
private repos: TRepositories,
|
|
private readonly sessionMaxAge = defaultSessionMaxAge,
|
|
private readonly sessionUpdateAge = defaulteSessionUpdateAge
|
|
) {}
|
|
|
|
async createUser(profile: UnsavedUser): Promise<SavedUser> {
|
|
// @ts-expect-error Typescript doesn't like lodash's omit()
|
|
return this.repos.users.upsert(omit(profile, ["isActive", "id"]));
|
|
}
|
|
|
|
async getUser(id: UserId): Promise<SavedUser | null> {
|
|
const user = await this.repos.users.findById({ id });
|
|
if (!user) return null;
|
|
// if a user has no linked accounts, then we do not return it
|
|
// see: https://github.com/nextauthjs/next-auth/issues/876
|
|
const accounts = await this.repos.accounts.findAllBy({
|
|
userId: user.id,
|
|
});
|
|
|
|
if (!accounts || accounts.length === 0) return null;
|
|
return user;
|
|
}
|
|
|
|
async getUserByEmail(email: string): Promise<SavedUser | null> {
|
|
const user = await this.repos.users.findBy({ email });
|
|
if (!user) return null;
|
|
// if a user has no linked accounts, then we do not return it
|
|
// see: https://github.com/nextauthjs/next-auth/issues/876
|
|
const accounts = await this.repos.accounts.findAllBy({
|
|
userId: user.id,
|
|
});
|
|
|
|
if (!accounts || accounts.length === 0) return null;
|
|
return user;
|
|
}
|
|
|
|
async getUserByAccount(
|
|
provider: string,
|
|
providerAccountId: string
|
|
): Promise<SavedUser | null> {
|
|
const account = await this.repos.accounts.findBy({
|
|
compoundId: getCompoundId(provider, providerAccountId),
|
|
});
|
|
if (!account) return null;
|
|
|
|
return this.repos.users.findById({ id: account.userId });
|
|
}
|
|
|
|
async updateUser(user: SavedUser): Promise<SavedUser> {
|
|
return this.repos.users.update(user);
|
|
}
|
|
|
|
async linkAccount(adapterAccount: AdapterAccount): Promise<void> {
|
|
const {
|
|
userId,
|
|
access_token: accessToken,
|
|
refresh_token: refreshToken,
|
|
provider: providerId,
|
|
providerAccountId,
|
|
expires_at: accessTokenExpires,
|
|
type: providerType,
|
|
} = adapterAccount;
|
|
const exists = await this.repos.users.existsById({ id: userId });
|
|
if (!exists) return;
|
|
const account: UnsavedAccount = {
|
|
accessToken,
|
|
refreshToken,
|
|
compoundId: getCompoundId(providerId, providerAccountId),
|
|
providerAccountId,
|
|
providerId,
|
|
providerType,
|
|
accessTokenExpires: accessTokenExpires
|
|
? new Date(accessTokenExpires)
|
|
: new Date(),
|
|
userId,
|
|
};
|
|
await this.repos.accounts.insert(account);
|
|
}
|
|
|
|
async unlinkAccount(
|
|
userId: string,
|
|
providerId: string,
|
|
providerAccountId: string
|
|
): Promise<void> {
|
|
await this.repos.accounts.removeBy({
|
|
userId,
|
|
compoundId: getCompoundId(providerId, providerAccountId),
|
|
});
|
|
}
|
|
|
|
createSession({
|
|
sessionToken,
|
|
userId,
|
|
}: {
|
|
sessionToken: string;
|
|
userId: string;
|
|
}): Promise<SavedSession> {
|
|
let expires;
|
|
if (this.sessionMaxAge) {
|
|
const dateExpires = new Date(Date.now() + this.sessionMaxAge);
|
|
expires = dateExpires.toISOString();
|
|
}
|
|
|
|
const session: UnsavedSession = {
|
|
expires,
|
|
userId,
|
|
sessionToken,
|
|
//sessionToken: randomToken(),
|
|
accessToken: randomToken(),
|
|
};
|
|
|
|
return this.repos.sessions.insert(session);
|
|
}
|
|
|
|
async getSessionAndUser(
|
|
sessionToken: string
|
|
): Promise<{ session: AdapterSession; user: any } | null> {
|
|
const session = await this.repos.sessions.findBy({ sessionToken });
|
|
if (!session) return null;
|
|
if (session && session.expires && new Date() > session.expires) {
|
|
this.repos.sessions.remove(session);
|
|
return null;
|
|
}
|
|
|
|
const user = await this.repos.users.findById({ id: session.userId });
|
|
if (!user) return null;
|
|
|
|
const adapterSession: AdapterSession = {
|
|
userId: session.userId,
|
|
expires: session.expires,
|
|
sessionToken: sessionToken,
|
|
};
|
|
|
|
const adapterUser: any = {
|
|
id: user.id,
|
|
email: user.email,
|
|
emailVerified: user.emailVerified,
|
|
userRole: user.userRole
|
|
};
|
|
|
|
return { session: adapterSession, user: adapterUser };
|
|
}
|
|
|
|
async updateSession(
|
|
session: SavedSession,
|
|
force?: boolean
|
|
): Promise<SavedSession | null> {
|
|
if (
|
|
this.sessionMaxAge &&
|
|
(this.sessionUpdateAge || this.sessionUpdateAge === 0) &&
|
|
session.expires
|
|
) {
|
|
// Calculate last updated date, to throttle write updates to database
|
|
// Formula: ({expiry date} - sessionMaxAge) + sessionUpdateAge
|
|
// e.g. ({expiry date} - 30 days) + 1 hour
|
|
//
|
|
// Default for sessionMaxAge is 30 days.
|
|
// Default for sessionUpdateAge is 1 hour.
|
|
const dateSessionIsDueToBeUpdated = new Date(
|
|
session.expires.getTime() - this.sessionMaxAge + this.sessionUpdateAge
|
|
);
|
|
|
|
// Trigger update of session expiry date and write to database, only
|
|
// if the session was last updated more than {sessionUpdateAge} ago
|
|
if (new Date() > dateSessionIsDueToBeUpdated) {
|
|
const newExpiryDate = new Date();
|
|
newExpiryDate.setTime(newExpiryDate.getTime() + this.sessionMaxAge);
|
|
session.expires = newExpiryDate;
|
|
} else if (!force) {
|
|
return null;
|
|
}
|
|
} else if (!force) {
|
|
// If session MaxAge, session UpdateAge or session.expires are
|
|
// missing then don't even try to save changes, unless force is set.
|
|
return null;
|
|
}
|
|
|
|
const { expires } = session;
|
|
return this.repos.sessions.update({ ...session, expires });
|
|
}
|
|
|
|
async deleteSession(sessionToken: string): Promise<void> {
|
|
await this.repos.sessions.removeBy({ sessionToken });
|
|
}
|
|
}
|