feat: add ldap and oidc support (#1497)

Co-authored-by: Thomas Camlong <49837342+ajnart@users.noreply.github.com>
Co-authored-by: Tagaishi <Tagaishi@hotmail.ch>
This commit is contained in:
Rikpat
2024-02-09 22:57:00 +01:00
committed by GitHub
parent b1ae5f700e
commit 9a8ea9e1fe
18 changed files with 923 additions and 249 deletions

166
src/utils/auth/adapter.ts Normal file
View File

@@ -0,0 +1,166 @@
import { randomUUID } from 'crypto';
import { and, eq } from 'drizzle-orm';
import {
BaseSQLiteDatabase,
SQLiteTableFn,
sqliteTable as defaultSqliteTableFn,
text,
} from 'drizzle-orm/sqlite-core';
import { User } from 'next-auth';
import { Adapter, AdapterAccount } from 'next-auth/adapters';
import { db } from '~/server/db';
import { _users, accounts, sessions, userSettings, verificationTokens } from '~/server/db/schema';
// Need to modify createTables with custom schema
const createTables = (sqliteTable: SQLiteTableFn) => ({
users: sqliteTable('user', {
..._users,
email: text('email').notNull(), // workaround for typescript
}),
accounts,
sessions,
verificationTokens,
});
export type DefaultSchema = ReturnType<typeof createTables>;
export const onCreateUser = async ({ user }: { user: User }) => {
await db.insert(userSettings).values({
id: randomUUID(),
userId: user.id,
});
};
// Keep this the same as original file @auth/drizzle-adapter/src/lib/sqlite.ts
// only change changed return type from Adapter to "satisfies Adapter", to tell typescript createUser exists
export function SQLiteDrizzleAdapter(
client: InstanceType<typeof BaseSQLiteDatabase>,
tableFn = defaultSqliteTableFn
) {
const { users, accounts, sessions, verificationTokens } = createTables(tableFn);
return {
createUser(data) {
return client
.insert(users)
.values({ ...data, id: crypto.randomUUID() })
.returning()
.get();
},
getUser(data) {
return client.select().from(users).where(eq(users.id, data)).get() ?? null;
},
getUserByEmail(data) {
return client.select().from(users).where(eq(users.email, data)).get() ?? null;
},
createSession(data) {
return client.insert(sessions).values(data).returning().get();
},
getSessionAndUser(data) {
return (
client
.select({
session: sessions,
user: users,
})
.from(sessions)
.where(eq(sessions.sessionToken, data))
.innerJoin(users, eq(users.id, sessions.userId))
.get() ?? null
);
},
updateUser(data) {
if (!data.id) {
throw new Error('No user id.');
}
return client.update(users).set(data).where(eq(users.id, data.id)).returning().get();
},
updateSession(data) {
return client
.update(sessions)
.set(data)
.where(eq(sessions.sessionToken, data.sessionToken))
.returning()
.get();
},
linkAccount(rawAccount) {
const updatedAccount = client.insert(accounts).values(rawAccount).returning().get();
const account: AdapterAccount = {
...updatedAccount,
type: updatedAccount.type,
access_token: updatedAccount.access_token ?? undefined,
token_type: updatedAccount.token_type ?? undefined,
id_token: updatedAccount.id_token ?? undefined,
refresh_token: updatedAccount.refresh_token ?? undefined,
scope: updatedAccount.scope ?? undefined,
expires_at: updatedAccount.expires_at ?? undefined,
session_state: updatedAccount.session_state ?? undefined,
};
return account;
},
getUserByAccount(account) {
const results = client
.select()
.from(accounts)
.leftJoin(users, eq(users.id, accounts.userId))
.where(
and(
eq(accounts.provider, account.provider),
eq(accounts.providerAccountId, account.providerAccountId)
)
)
.get();
return results?.user ?? null;
},
deleteSession(sessionToken) {
return (
client.delete(sessions).where(eq(sessions.sessionToken, sessionToken)).returning().get() ??
null
);
},
createVerificationToken(token) {
return client.insert(verificationTokens).values(token).returning().get();
},
useVerificationToken(token) {
try {
return (
client
.delete(verificationTokens)
.where(
and(
eq(verificationTokens.identifier, token.identifier),
eq(verificationTokens.token, token.token)
)
)
.returning()
.get() ?? null
);
} catch (err) {
throw new Error('No verification token found.');
}
},
deleteUser(id) {
return client.delete(users).where(eq(users.id, id)).returning().get();
},
unlinkAccount(account) {
client
.delete(accounts)
.where(
and(
eq(accounts.providerAccountId, account.providerAccountId),
eq(accounts.provider, account.provider)
)
)
.run();
return undefined;
},
} satisfies Adapter;
}
export default SQLiteDrizzleAdapter(db);

View File

@@ -0,0 +1,56 @@
import bcrypt from 'bcryptjs';
import Consola from 'consola';
import { eq } from 'drizzle-orm';
import Credentials from 'next-auth/providers/credentials';
import { colorSchemeParser, signInSchema } from '~/validations/user';
import { db } from '../../server/db';
import { users } from '../../server/db/schema';
export default Credentials({
name: 'credentials',
credentials: {
name: {
label: 'Username',
type: 'text',
},
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
const data = await signInSchema.parseAsync(credentials);
const user = await db.query.users.findFirst({
with: {
settings: {
columns: {
colorScheme: true,
language: true,
autoFocusSearch: true,
},
},
},
where: eq(users.name, data.name),
});
if (!user || !user.password) {
return null;
}
Consola.log(`user ${user.name} is trying to log in. checking password...`);
const isValidPassword = await bcrypt.compare(data.password, user.password);
if (!isValidPassword) {
Consola.log(`password for user ${user.name} was incorrect`);
return null;
}
Consola.log(`user ${user.name} successfully authorized`);
return {
id: user.id,
name: user.name,
isAdmin: false,
isOwner: false,
};
},
});

46
src/utils/auth/index.ts Normal file
View File

@@ -0,0 +1,46 @@
import { DefaultSession } from 'next-auth';
import { CredentialsConfig, OAuthConfig } from 'next-auth/providers';
import { env } from '~/env';
export { default as adapter, onCreateUser } from './adapter';
/**
* Module augmentation for `next-auth` types. Allows us to add custom properties to the `session`
* object and keep type safety.
*
* @see https://next-auth.js.org/getting-started/typescript#module-augmentation
*/
declare module 'next-auth' {
interface Session extends DefaultSession {
user: DefaultSession['user'] & {
id: string;
isAdmin: boolean;
colorScheme: 'light' | 'dark' | 'environment';
autoFocusSearch: boolean;
language: string;
// ...other properties
// role: UserRole;
};
}
interface User {
isAdmin: boolean;
isOwner?: boolean;
// ...other properties
// role: UserRole;
}
}
declare module 'next-auth/jwt' {
interface JWT {
id: string;
isAdmin: boolean;
}
}
export const providers: (CredentialsConfig | OAuthConfig<any>)[] = [];
if (env.AUTH_PROVIDER?.includes('ldap')) providers.push((await import('./ldap')).default);
if (env.AUTH_PROVIDER?.includes('credentials'))
providers.push((await import('./credentials')).default);
if (env.AUTH_PROVIDER?.includes('oidc')) providers.push((await import('./oidc')).default);

161
src/utils/auth/ldap.ts Normal file
View File

@@ -0,0 +1,161 @@
import Consola from 'consola';
import ldap from 'ldapjs';
import Credentials from 'next-auth/providers/credentials';
import { env } from '~/env';
import { signInSchema } from '~/validations/user';
import adapter, { onCreateUser } from './adapter';
// Helper types for infering properties of returned search type
type AttributeConstraint = string | readonly string[] | undefined;
type InferrableSearchOptions<
Attributes extends AttributeConstraint,
ArrayAttributes extends Attributes,
> = Omit<ldap.SearchOptions, 'attributes'> & {
attributes?: Attributes;
arrayAttributes?: ArrayAttributes;
};
type SearchResultIndex<Attributes extends AttributeConstraint> = Attributes extends string
? Attributes
: Attributes extends readonly string[]
? Attributes[number]
: string;
type SearchResult<
Attributes extends AttributeConstraint,
ArrayAttributes extends Attributes = never,
> = { dn: string } & Record<
Exclude<SearchResultIndex<Attributes>, SearchResultIndex<ArrayAttributes>>,
string
> &
Record<SearchResultIndex<ArrayAttributes>, string[]>;
const ldapLogin = (username: string, password: string) =>
new Promise<ldap.Client>((resolve, reject) => {
const client = ldap.createClient({
url: env.AUTH_LDAP_URI,
});
client.bind(username, password, (error, res) => {
if (error) {
reject('Invalid username or password');
} else {
resolve(client);
}
});
});
const ldapSearch = async <
Attributes extends AttributeConstraint,
ArrayAttributes extends Attributes = never,
>(
client: ldap.Client,
base: string,
options: InferrableSearchOptions<Attributes, ArrayAttributes>
) =>
new Promise<SearchResult<Attributes, ArrayAttributes>[]>((resolve, reject) => {
client.search(base, options as ldap.SearchOptions, (err, res) => {
const results: SearchResult<Attributes, ArrayAttributes>[] = [];
res.on('error', (err) => {
reject('error: ' + err.message);
});
res.on('searchEntry', (entry) => {
results.push(
entry.pojo.attributes.reduce<Record<string, string | string[]>>(
(obj, attr) => {
// just take first element assuming there's only one (uid, mail), unless in arrayAttributes
obj[attr.type] = options.arrayAttributes?.includes(attr.type)
? attr.values
: attr.values[0];
return obj;
},
{ dn: entry.pojo.objectName }
) as SearchResult<Attributes, ArrayAttributes>
);
});
res.on('end', (result) => {
if (result?.status != 0) {
reject(new Error('ldap search status is not 0, search failed'));
} else {
resolve(results);
}
});
});
});
export default Credentials({
id: 'ldap',
name: 'LDAP',
credentials: {
name: { label: 'uid', type: 'text' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
try {
const data = await signInSchema.parseAsync(credentials);
Consola.log(`user ${data.name} is trying to log in using LDAP. Signing in...`);
const client = await ldapLogin(env.AUTH_LDAP_BIND_DN, env.AUTH_LDAP_BIND_PASSWORD);
const ldapUser = (
await ldapSearch(client, env.AUTH_LDAP_BASE, {
filter: `(uid=${data.name})`,
// as const for inference
attributes: ['uid', 'mail'] as const,
})
)[0];
await ldapLogin(ldapUser.dn, data.password).then((client) => client.destroy());
const userGroups = (
await ldapSearch(client, env.AUTH_LDAP_BASE, {
filter: `(&(objectclass=${env.AUTH_LDAP_GROUP_CLASS})(${
env.AUTH_LDAP_GROUP_MEMBER_ATTRIBUTE
}=${ldapUser[env.AUTH_LDAP_GROUP_MEMBER_USER_ATTRIBUTE as 'dn' | 'uid']}))`,
// as const for inference
attributes: 'cn',
})
).map((group) => group.cn);
client.destroy();
Consola.log(`user ${data.name} successfully authorized`);
let user = await adapter.getUserByEmail!(ldapUser.mail);
const isAdmin = userGroups.includes(env.AUTH_LDAP_ADMIN_GROUP);
const isOwner = userGroups.includes(env.AUTH_LDAP_OWNER_GROUP);
if (!user) {
// CreateUser will create settings in event
user = await adapter.createUser({
name: ldapUser.uid,
email: ldapUser.mail,
emailVerified: new Date(), // assume ldap email is verified
isAdmin: isAdmin,
isOwner: isOwner,
});
// For some reason adapter.createUser doesn't call createUser event, needs to be called manually to create usersettings
await onCreateUser({ user });
} else if (user.isAdmin != isAdmin || user.isOwner != isOwner) {
// Update roles if changed in LDAP
Consola.log(`updating roles of user ${user.name}`);
adapter.updateUser({
...user,
isAdmin,
isOwner,
});
}
return {
id: user?.id || ldapUser.dn,
name: user?.name || ldapUser.uid,
isAdmin: isAdmin,
isOwner: isOwner,
};
} catch (error) {
Consola.error(error);
return null;
}
},
});

51
src/utils/auth/oidc.ts Normal file
View File

@@ -0,0 +1,51 @@
import Consola from 'consola';
import { OAuthConfig } from 'next-auth/providers/oauth';
import { env } from '~/env';
import adapter from './adapter';
type Profile = {
sub: string;
name: string;
email: string;
groups: string[];
preferred_username: string;
email_verified: boolean;
};
const provider: OAuthConfig<Profile> = {
id: 'oidc',
name: env.AUTH_OIDC_CLIENT_NAME,
type: 'oauth',
clientId: env.AUTH_OIDC_CLIENT_ID,
clientSecret: env.AUTH_OIDC_CLIENT_SECRET,
wellKnown: `${env.AUTH_OIDC_URI}/.well-known/openid-configuration`,
authorization: { params: { scope: 'openid email profile groups' } },
idToken: true,
async profile(profile) {
const user = await adapter.getUserByEmail!(profile.email);
const isAdmin = profile.groups.includes(env.AUTH_OIDC_ADMIN_GROUP);
const isOwner = profile.groups.includes(env.AUTH_OIDC_OWNER_GROUP);
// check for role update
if (user && (user.isAdmin != isAdmin || user.isOwner != isOwner)) {
Consola.log(`updating roles of user ${user.name}`);
adapter.updateUser({
...user,
isAdmin,
isOwner,
});
}
return {
id: profile.sub,
name: profile.preferred_username,
email: profile.email,
isAdmin,
isOwner,
};
},
};
export default provider;