feat(db/oauth2): Added oauth2 handling to database

- Database: edited dmbl/sql for oauth2 changes
- Database/oauth2: new oauth2 mixin
- Database/user: exported raw functions to be used in oauth2 mixin
This commit is contained in:
Maieul BOYER 2025-10-25 15:39:49 +02:00 committed by Maix0
parent 26627cd4d7
commit bc7a615dcf
5 changed files with 98 additions and 7 deletions

View file

@ -1,14 +1,16 @@
import fp from 'fastify-plugin';
import { FastifyInstance, FastifyPluginAsync } from 'fastify';
import { Database as DbImpl } from './mixin/_base';
import { UserImpl, IUserDb } from './mixin/user';
import { isNullish } from '@shared/utils';
import { Database as DbImpl } from './mixin/_base';
import { IOauthDb, OauthImpl } from './mixin/oauth2';
import { IUserDb, UserImpl } from './mixin/user';
Object.assign(DbImpl.prototype, UserImpl);
Object.assign(DbImpl.prototype, OauthImpl);
export interface Database extends DbImpl, IUserDb { }
export interface Database extends DbImpl, IUserDb, IOauthDb { }
// When using .decorate you have to specify added properties for Typescript
declare module 'fastify' {

View file

@ -17,9 +17,10 @@ Project Transcendance {
Table user {
id text [PK, not null]
name text [unique, not null]
name text [not null]
password text [null, Note: "If password is NULL, this means that the user is created through OAUTH2"]
otp text [null, Note: "If otp is NULL, then the user didn't configure 2FA"]
guest integer [not null, default: 0]
Note: "Represent a user"
}
@ -27,7 +28,7 @@ Table user {
Table auth {
id integer [PK, not null, increment]
provider text [not null]
user integer [ref: > user.id, not null]
user text [ref: > user.id, not null]
oauth2_user text [not null, unique, Note: '''
This makes sure that an oauth2 login is the always the same `user`
Aka can't have two account bound to the same <OAUTH2> account

View file

@ -1,6 +1,6 @@
CREATE TABLE IF NOT EXISTS user (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
password TEXT,
otp TEXT,
guest INTEGER NOT NULL DEFAULT 0

View file

@ -0,0 +1,88 @@
// import type { Database } from './_base';
import { isNullish } from '@shared/utils';
import { UserId, IUserDb, userFromRow, User } from './user';
// never use this directly
export interface IOauthDb extends IUserDb {
getAllFromProvider(this: IOauthDb, provider: string): ProviderUser[],
getProviderUser(this: IOauthDb, provider: string, unique_id: string): ProviderUser | undefined,
getUserFromProviderUser(this: IOauthDb, provider: string, unique_id: string): User | undefined,
getUserFromProviderUserId(this: IOauthDb, id: ProviderUserId): User | undefined,
getProviderUserFromId(this: IOauthDb, id: ProviderUserId): ProviderUser | undefined,
createUserWithProvider(this: IOauthDb, provider: string, unique_id: string, username: string): Promise<User | undefined>,
};
export const OauthImpl: Omit<IOauthDb, keyof IUserDb> = {
getAllFromProvider(this: IOauthDb, provider: string): ProviderUser[] {
return this.prepare('SELECT * FROM auth WHERE provider = @provider')
.all({ provider })
.map(r => providerUserFromRow(r as Partial<ProviderUser> | undefined))
.filter(v => !isNullish(v)) ?? [];
},
getProviderUser(this: IOauthDb, provider: string, unique_id: string): ProviderUser | undefined {
unique_id = `provider:${unique_id}`;
return providerUserFromRow(this.prepare('SELECT * FROM auth WHERE provider = @provider AND oauth2_user = @unique_id')
.get({ provider, unique_id }) as Partial<ProviderUser> | undefined);
},
getProviderUserFromId(this: IOauthDb, id: ProviderUserId): ProviderUser | undefined {
return providerUserFromRow(this.prepare('SELECT * FROM auth WHERE id = @id')
.get({ id }) as Partial<ProviderUser> | undefined);
},
getUserFromProviderUser(this: IOauthDb, provider: string, unique_id: string): User | undefined {
unique_id = `provider:${unique_id}`;
return userFromRow(
this.prepare('SELECT user.* from auth INNER JOIN user ON user.id = auth.user WHERE auth.provider = @provider AND auth.oauth2_user = @unique_id')
.get({ provider, unique_id }) as Partial<User> | undefined,
);
},
getUserFromProviderUserId(this: IOauthDb, id: ProviderUserId): User | undefined {
return userFromRow(
this.prepare('SELECT user.* from auth INNER JOIN user ON user.id = auth.user WHERE auth.id = @id')
.get({ id }) as Partial<User> | undefined,
);
},
async createUserWithProvider(this: IOauthDb, provider: string, unique_id: string, username: string): Promise<User | undefined> {
unique_id = `provider:${unique_id}`;
const user = await this.createUser(username, undefined, false);
if (isNullish(user)) { return undefined; }
this.prepare('INSERT INTO auth (provider, user, oauth2_user) VALUES (@provider, @user_id, @unique_id)').run({ provider, user_id: user.id, unique_id });
return user;
},
};
export type ProviderUserId = number & { readonly __brand: unique symbol };
export type ProviderUser = {
readonly id: ProviderUserId,
readonly provider: string,
readonly user: UserId,
readonly oauth2_user: string,
};
/**
* Get a user from a row
*
* @param row The data from sqlite
*
* @returns The user if it exists, undefined otherwise
*/
function providerUserFromRow(row?: Partial<ProviderUser>): ProviderUser | undefined {
if (isNullish(row)) return undefined;
if (isNullish(row.id)) return undefined;
if (isNullish(row.provider)) return undefined;
if (isNullish(row.user)) return undefined;
if (isNullish(row.oauth2_user)) return undefined;
return {
id: row.id,
provider: row.provider,
user: row.user,
oauth2_user: row.oauth2_user,
};
}

View file

@ -146,7 +146,7 @@ async function hashPassword(
*
* @returns The user if it exists, undefined otherwise
*/
function userFromRow(row?: Partial<User>): User | undefined {
export function userFromRow(row?: Partial<User>): User | undefined {
if (isNullish(row)) return undefined;
if (isNullish(row.id)) return undefined;
if (isNullish(row.name)) return undefined;