link-stack/packages/metamigo-common/src/controllers/nextauth-adapter.ts

213 lines
6.6 KiB
TypeScript
Raw Normal View History

2023-02-13 12:41:30 +00:00
/* eslint-disable unicorn/no-null,max-params */
2023-03-13 11:46:12 +00:00
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";
2023-02-13 12:41:30 +00:00
// 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");
2023-05-26 08:27:16 +00:00
export class NextAuthAdapter<TRepositories extends IMetamigoRepositories> {
2023-02-13 12:41:30 +00:00
constructor(
private repos: TRepositories,
private readonly sessionMaxAge = defaultSessionMaxAge,
private readonly sessionUpdateAge = defaulteSessionUpdateAge
) {}
2023-02-13 12:41:30 +00:00
async createUser(profile: UnsavedUser): Promise<SavedUser> {
2023-03-13 11:46:12 +00:00
// @ts-expect-error Typescript doesn't like lodash's omit()
2023-02-13 12:41:30 +00:00
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,
2023-02-13 12:41:30 +00:00
providerAccountId: string
): Promise<SavedUser | null> {
const account = await this.repos.accounts.findBy({
compoundId: getCompoundId(provider, providerAccountId),
2023-02-13 12:41:30 +00:00
});
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;
2023-02-13 12:41:30 +00:00
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> {
2023-02-13 12:41:30 +00:00
let expires;
if (this.sessionMaxAge) {
const dateExpires = new Date(Date.now() + this.sessionMaxAge);
expires = dateExpires.toISOString();
}
const session: UnsavedSession = {
expires,
userId,
sessionToken,
//sessionToken: randomToken(),
2023-02-13 12:41:30 +00:00
accessToken: randomToken(),
};
return this.repos.sessions.insert(session);
}
async getSessionAndUser(
sessionToken: string
): Promise<{ session: AdapterSession; user: AdapterUser } | null> {
2023-02-13 12:41:30 +00:00
const session = await this.repos.sessions.findBy({ sessionToken });
if (!session) return null;
2023-02-13 12:41:30 +00:00
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: AdapterUser = {
id: user.id,
email: user.email,
emailVerified: user.emailVerified,
};
return { session: adapterSession, user: adapterUser };
2023-02-13 12:41:30 +00:00
}
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 });
}
}