feat(openapi): Started working on Openapi generation

- Updated to Typebox 1.0.0 to better support Openapi type generation
- Changed dockerfile to fetch depedencies only once
- Fixed Routes to properly handle openapi
- Fixed Routes to respond with multiples status code (no more only 200)
- Fixed Schemas so the auth-gated endpoint properly reflect that
- Added Makefile rule to generate openapi client (none working due to
  missing files)
This commit is contained in:
Maieul BOYER 2025-11-09 02:44:18 +01:00 committed by Maix0
parent 1bd2b4594b
commit b7c2a3dff9
36 changed files with 5472 additions and 833 deletions

849
src/auth/openapi.json Normal file
View file

@ -0,0 +1,849 @@
{
"openapi": "3.1.0",
"info": {
"version": "9.6.0",
"title": "@fastify/swagger"
},
"components": {
"schemas": {}
},
"paths": {
"/api/auth/disableOtp": {
"put": {
"operationId": "disableOtp",
"responses": {
"200": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"success"
]
},
"msg": {
"enum": [
"disableOtp.success"
]
}
}
}
}
}
},
"401": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"notLoggedIn"
]
},
"msg": {
"enum": [
"auth.noCookie",
"auth.invalidKind",
"auth.noUser",
"auth.invalid"
]
}
}
}
}
}
},
"500": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"failure"
]
},
"msg": {
"enum": [
"disableOtp.failure.generic"
]
}
}
}
}
}
}
}
}
},
"/api/auth/enableOtp": {
"put": {
"operationId": "enableOtp",
"responses": {
"200": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg",
"payload"
],
"properties": {
"kind": {
"enum": [
"success"
]
},
"msg": {
"enum": [
"enableOtp.success"
]
},
"payload": {
"type": "object",
"required": [
"url"
],
"properties": {
"url": {
"type": "string",
"description": "The otp url to feed into a 2fa app"
}
}
}
}
}
}
}
},
"401": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"notLoggedIn"
]
},
"msg": {
"enum": [
"auth.noCookie",
"auth.invalidKind",
"auth.noUser",
"auth.invalid"
]
}
}
}
}
}
}
}
}
},
"/api/auth/guest": {
"post": {
"operationId": "guestLogin",
"responses": {
"200": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg",
"payload"
],
"properties": {
"kind": {
"enum": [
"success"
]
},
"msg": {
"enum": [
"guestLogin.success"
]
},
"payload": {
"type": "object",
"required": [
"token"
],
"properties": {
"token": {
"type": "string",
"description": "JWT that represent a logged in user"
}
}
}
}
}
}
}
},
"500": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"failed"
]
},
"msg": {
"enum": [
"guestLogin.failed.generic.unknown",
"guestLogin.failed.generic.error"
]
}
}
}
}
}
}
}
}
},
"/api/auth/login": {
"post": {
"operationId": "login",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"name",
"password"
],
"properties": {
"name": {
"type": "string"
},
"password": {
"type": "string"
}
}
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg",
"payload"
],
"properties": {
"kind": {
"enum": [
"success"
]
},
"msg": {
"enum": [
"login.success"
]
},
"payload": {
"type": "object",
"required": [
"token"
],
"properties": {
"token": {
"type": "string",
"description": "JWT that represent a logged in user"
}
}
}
}
}
}
}
},
"202": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg",
"payload"
],
"properties": {
"kind": {
"enum": [
"otpRequired"
]
},
"msg": {
"enum": [
"login.otpRequired"
]
},
"payload": {
"type": "object",
"required": [
"token"
],
"properties": {
"token": {
"type": "string",
"description": "JWT to send with the OTP to finish login"
}
}
}
}
}
}
}
},
"400": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"failed"
]
},
"msg": {
"enum": [
"login.failed.generic",
"login.failed.invalid"
]
}
}
}
}
}
}
}
}
},
"/api/auth/logout": {
"post": {
"operationId": "logout",
"responses": {
"200": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"success"
]
},
"msg": {
"enum": [
"logout.success"
]
}
}
}
}
}
}
}
}
},
"/api/auth/otp": {
"post": {
"operationId": "loginOtp",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"token",
"code"
],
"properties": {
"token": {
"type": "string",
"description": "The token given at the login phase"
},
"code": {
"type": "string",
"description": "The OTP given by the user"
}
}
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg",
"payload"
],
"properties": {
"kind": {
"enum": [
"success"
]
},
"msg": {
"enum": [
"otp.success"
]
},
"payload": {
"type": "object",
"required": [
"token"
],
"properties": {
"token": {
"type": "string",
"description": "the JWT Token"
}
}
}
}
}
}
}
},
"400": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"failed"
]
},
"msg": {
"enum": [
"otp.failed.invalid"
]
}
}
}
}
}
},
"401": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"failed"
]
},
"msg": {
"enum": [
"otp.failed.noSecret"
]
}
}
}
}
}
},
"408": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"failed"
]
},
"msg": {
"enum": [
"otp.failed.timeout"
]
}
}
}
}
}
},
"500": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"failed"
]
},
"msg": {
"enum": [
"otp.failed.generic"
]
}
}
}
}
}
}
}
}
},
"/api/auth/signin": {
"post": {
"operationId": "signin",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"name",
"password"
],
"properties": {
"name": {
"type": "string"
},
"password": {
"type": "string"
}
}
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg",
"payload"
],
"properties": {
"kind": {
"enum": [
"success"
]
},
"msg": {
"enum": [
"signin.success"
]
},
"payload": {
"type": "object",
"required": [
"token"
],
"properties": {
"token": {
"type": "string",
"description": "the JWT token"
}
}
}
}
}
}
}
},
"400": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"failed"
]
},
"msg": {
"enum": [
"signin.failed.username.existing",
"signin.failed.username.toolong",
"signin.failed.username.tooshort",
"signin.failed.username.invalid",
"signin.failed.password.toolong",
"signin.failed.password.tooshort",
"signin.failed.password.invalid"
]
}
}
}
}
}
},
"500": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"failed"
]
},
"msg": {
"enum": [
"signin.failed.generic"
]
}
}
}
}
}
}
}
}
},
"/api/auth/statusOtp": {
"get": {
"operationId": "statusOtp",
"responses": {
"200": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"type": "object",
"required": [
"kind",
"msg",
"payload"
],
"properties": {
"kind": {
"enum": [
"success"
]
},
"msg": {
"enum": [
"statusOtp.success.enabled"
]
},
"payload": {
"type": "object",
"required": [
"url"
],
"properties": {
"url": {
"type": "string",
"description": "The otp url to feed into a 2fa app"
}
}
}
}
},
{
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"success"
]
},
"msg": {
"enum": [
"statusOtp.success.disabled"
]
}
}
}
]
}
}
}
},
"401": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"notLoggedIn"
]
},
"msg": {
"enum": [
"auth.noCookie",
"auth.invalidKind",
"auth.noUser",
"auth.invalid"
]
}
}
}
}
}
},
"500": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"failure"
]
},
"msg": {
"enum": [
"statusOtp.failure.generic"
]
}
}
}
}
}
}
}
}
}
},
"servers": [
{
"url": "https://local.maix.me:8888",
"description": "direct from docker"
},
{
"url": "https://local.maix.me:8000",
"description": "using fnginx"
}
]
}

View file

@ -11,7 +11,8 @@
"scripts": {
"start": "npm run build && node dist/run.js",
"build": "vite build",
"build:prod": "vite build --outDir=/dist --minify=true --sourcemap=false"
"build:prod": "vite build --outDir=/dist --minify=true --sourcemap=false",
"build:openapi": "VITE_ENTRYPOINT=src/openapi.ts vite build && node dist/openapi.cjs >openapi.json"
},
"keywords": [],
"author": "",
@ -22,16 +23,16 @@
"@fastify/multipart": "^9.3.0",
"@fastify/sensible": "^6.0.3",
"@fastify/static": "^8.3.0",
"@sinclair/typebox": "^0.34.41",
"typebox": "^1.0.51",
"confbox": "^0.2.2",
"fastify": "^5.6.1",
"fastify-cli": "^7.4.0",
"fastify-cli": "^7.4.1",
"fastify-plugin": "^5.1.0"
},
"devDependencies": {
"@types/node": "^22.18.13",
"rollup-plugin-node-externals": "^8.1.1",
"vite": "^7.1.12",
"@types/node": "^22.19.0",
"rollup-plugin-node-externals": "^8.1.2",
"vite": "^7.2.2",
"vite-tsconfig-paths": "^5.1.4"
}
}

View file

@ -3,6 +3,10 @@ import fastifyFormBody from '@fastify/formbody';
import fastifyMultipart from '@fastify/multipart';
import * as db from '@shared/database';
import * as auth from '@shared/auth';
import * as swagger from '@shared/swagger';
import * as utils from '@shared/utils';
declare const __SERVICE_NAME: string;
// @ts-expect-error: import.meta.glob is a vite thing. Typescript doesn't know this...
const plugins = import.meta.glob('./plugins/**/*.ts', { eager: true });
@ -18,6 +22,8 @@ declare module 'fastify' {
const app: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
void opts;
await fastify.register(utils.useMakeResponse);
await fastify.register(swagger.useSwagger, { service: __SERVICE_NAME });
await fastify.register(db.useDatabase as FastifyPluginAsync, {});
await fastify.register(auth.jwtPlugin as FastifyPluginAsync, {});
await fastify.register(auth.authPlugin as FastifyPluginAsync, {});

View file

@ -1,7 +1,7 @@
import { isNullish } from '@shared/utils';
import type { Provider, ProviderSecret, ProviderUserInfo } from './plugins/providers';
import Type, { Static } from '@sinclair/typebox';
import Value from '@sinclair/typebox/value';
import Type, { Static } from 'typebox';
import Value from 'typebox/value';
import { createHash, randomBytes } from 'node:crypto';
// An openid manifest is a standard json object, which all required data to use the service.

21
src/auth/src/openapi.ts Normal file
View file

@ -0,0 +1,21 @@
import f, { FastifyPluginAsync } from 'fastify';
import * as swagger from '@shared/swagger';
import * as auth from '@shared/auth';
declare const __SERVICE_NAME: string;
// @ts-expect-error: import.meta.glob is a vite thing. Typescript doesn't know this...
const routes = import.meta.glob('./routes/**/*.ts', { eager: true });
async function start() {
const fastify = f({ logger: false });
await fastify.register(auth.authPlugin, { onlySchema: true });
await fastify.register(swagger.useSwagger, { service: __SERVICE_NAME });
for (const route of Object.values(routes)) {
await fastify.register(route as FastifyPluginAsync, {});
}
await fastify.ready();
console.log(JSON.stringify(fastify.swagger(), undefined, 4));
}
start();

View file

@ -1,8 +1,8 @@
import { isNullish } from '@shared/utils';
import fp from 'fastify-plugin';
import { readFile } from 'node:fs/promises';
import * as T from '@sinclair/typebox';
import * as V from '@sinclair/typebox/value';
import * as T from 'typebox';
import * as V from 'typebox/value';
import { Oauth2 } from '../oauth2';
import { parseTOML } from 'confbox';

View file

@ -1,26 +1,25 @@
import { FastifyPluginAsync } from 'fastify';
import { Static, Type } from '@sinclair/typebox';
import { makeResponse, typeResponse, isNullish } from '@shared/utils';
// import { Static, Type } from 'typebox';
import { typeResponse, isNullish } from '@shared/utils';
export const WhoAmIRes = Type.Union([
typeResponse('success', 'disableOtp.success'),
typeResponse('failure', 'disableOtp.failure.generic'),
]);
export const DisableOtpRes = {
'200': typeResponse('success', 'disableOtp.success'),
'500': typeResponse('failure', 'disableOtp.failure.generic'),
};
export type WhoAmIRes = Static<typeof WhoAmIRes>;
const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
void _opts;
fastify.put(
'/api/auth/disableOtp',
{ schema: { response: { '2xx': WhoAmIRes } }, config: { requireAuth: true } },
async function(req, _res) {
void _res;
if (isNullish(req.authUser)) {return makeResponse('failure', 'disableOtp.failure.generic');}
{ schema: { response: DisableOtpRes, operationId: 'disableOtp' }, config: { requireAuth: true } },
async function(req, res) {
void res;
if (isNullish(req.authUser)) { return res.makeResponse(500, 'failure', 'disableOtp.failure.generic'); }
this.db.deleteUserOtpSecret(req.authUser.id);
return makeResponse('success', 'disableOtp.success');
return res.makeResponse(200, 'success', 'disableOtp.success');
},
);
};

View file

@ -1,29 +1,33 @@
import { FastifyPluginAsync } from 'fastify';
import { Static, Type } from '@sinclair/typebox';
import { isNullish, makeResponse, typeResponse } from '@shared/utils';
import { Type } from 'typebox';
import { isNullish, MakeStaticResponse, typeResponse } from '@shared/utils';
import { Otp } from '@shared/auth';
export const WhoAmIRes = Type.Union([
typeResponse('success', 'enableOtp.success', { url: Type.String({ description: 'The otp url to feed into a 2fa app' }) }),
typeResponse('failure', ['enableOtp.failure.noUser', 'enableOtp.failure.noSecret']),
]);
export const EnableOtpRes = {
'200': typeResponse('success', 'enableOtp.success', {
url: Type.String({ description: 'The otp url to feed into a 2fa app' }),
}),
'401': typeResponse('failure', ['enableOtp.failure.noUser', 'enableOtp.failure.noSecret']),
};
export type WhoAmIRes = Static<typeof WhoAmIRes>;
export type EnableOtpRes = MakeStaticResponse<typeof EnableOtpRes>;
const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
void _opts;
fastify.put(
fastify.put<{ Reply: EnableOtpRes }>(
'/api/auth/enableOtp',
{ schema: { response: { '2xx': WhoAmIRes } }, config: { requireAuth: true } },
async function(req, _res) {
void _res;
if (isNullish(req.authUser)) {return makeResponse('failure', 'enableOtp.failure.noUser');}
{ schema: { response: EnableOtpRes, operationId: 'enableOtp' }, config: { requireAuth: true } },
async function(req, res) {
if (isNullish(req.authUser)) { return res.makeResponse(403, 'failure', 'enableOtp.failure.noUser'); }
const otpSecret = this.db.ensureUserOtpSecret(req.authUser!.id);
if (isNullish(otpSecret)) {return makeResponse('failure', 'enableOtp.failure.noSecret');}
if (isNullish(otpSecret)) { return res.makeResponse(403, 'failure', 'enableOtp.failure.noSecret'); }
const otp = new Otp({ secret: otpSecret });
return makeResponse('success', 'enableOtp.success', { url: otp.totpURL });
return res.makeResponse(200, 'success', 'enableOtp.success', { url: otp.totpURL });
},
);
};

View file

@ -1,18 +1,18 @@
import { FastifyPluginAsync } from 'fastify';
import { Static, Type } from '@sinclair/typebox';
import { typeResponse, makeResponse, isNullish } from '@shared/utils';
import { Type } from 'typebox';
import { typeResponse, isNullish, MakeStaticResponse } from '@shared/utils';
export const GuestLoginRes = Type.Union([
typeResponse('failed', ['guestLogin.failed.generic.unknown', 'guestLogin.failed.generic.error']),
typeResponse('success', 'guestLogin.success', {
export const GuestLoginRes = {
'500': typeResponse('failed', ['guestLogin.failed.generic.unknown', 'guestLogin.failed.generic.error']),
'200': typeResponse('success', 'guestLogin.success', {
token: Type.String({
description: 'JWT that represent a logged in user',
}),
}),
]);
};
export type GuestLoginRes = Static<typeof GuestLoginRes>;
export type GuestLoginRes = MakeStaticResponse<typeof GuestLoginRes>;
const getRandomFromList = (list: string[]): string => {
return list[Math.floor(Math.random() * list.length)];
@ -20,9 +20,9 @@ const getRandomFromList = (list: string[]): string => {
const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
void _opts;
fastify.post(
fastify.post<{ Body: null, Reply: GuestLoginRes }>(
'/api/auth/guest',
{ schema: { response: { '2xx': GuestLoginRes } } },
{ schema: { response: GuestLoginRes, operationId: 'guestLogin' } },
async function(req, res) {
void req;
void res;
@ -40,15 +40,15 @@ const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
true,
);
if (isNullish(user)) {
return makeResponse('failed', 'guestLogin.failed.generic.unknown');
return res.makeResponse(500, 'failed', 'guestLogin.failed.generic.unknown');
}
return makeResponse('success', 'guestLogin.success', {
return res.makeResponse(200, 'success', 'guestLogin.success', {
token: this.signJwt('auth', user.id.toString()),
});
}
catch (e: unknown) {
fastify.log.error(e);
return makeResponse('failed', 'guestLogin.failed.generic.error');
return res.makeResponse(500, 'failed', 'guestLogin.failed.generic.error');
}
},
);

View file

@ -1,7 +1,7 @@
import { FastifyPluginAsync } from 'fastify';
import { Static, Type } from '@sinclair/typebox';
import { typeResponse, makeResponse, isNullish } from '@shared/utils';
import { Static, Type } from 'typebox';
import { typeResponse, isNullish, MakeStaticResponse } from '@shared/utils';
import { verifyUserPassword } from '@shared/database/mixin/user';
export const LoginReq = Type.Object({
@ -11,45 +11,45 @@ export const LoginReq = Type.Object({
export type LoginReq = Static<typeof LoginReq>;
export const LoginRes = Type.Union([
typeResponse('failed', ['login.failed.generic', 'login.failed.invalid']),
typeResponse('otpRequired', 'login.otpRequired', { token: Type.String({ description: 'JWT to send with the OTP to finish login' }) }),
typeResponse('success', 'login.success', { token: Type.String({ description: 'JWT that represent a logged in user' }) }),
]);
export const LoginRes = {
'400': typeResponse('failed', ['login.failed.generic', 'login.failed.invalid']),
'200': typeResponse('success', 'login.success', { token: Type.String({ description: 'JWT that represent a logged in user' }) }),
'202': typeResponse('otpRequired', 'login.otpRequired', {
token: Type.String({ description: 'JWT to send with the OTP to finish login' }),
}),
};
export type LoginRes = Static<typeof LoginRes>;
export type LoginRes = MakeStaticResponse<typeof LoginRes>;
const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
void _opts;
fastify.post<{ Body: LoginReq; Response: LoginRes }>(
'/api/auth/login',
{ schema: { body: LoginReq, response: { '2xx': LoginRes } } },
async function(req, _res) {
void _res;
{ schema: { body: LoginReq, response: LoginRes, operationId: 'login' } },
async function(req, res) {
try {
const { name, password } = req.body;
const user = this.db.getUserFromLoginName(name);
// does the user exist
// does it have a password setup ?
if (isNullish(user?.password)) {return makeResponse('failed', 'login.failed.invalid');}
if (isNullish(user?.password)) { return res.makeResponse(403, 'failed', 'login.failed.invalid'); }
// does the password he provided match the one we have
if (!(await verifyUserPassword(user, password))) {return makeResponse('failed', 'login.failed.invalid');}
if (!(await verifyUserPassword(user, password))) { return res.makeResponse(403, 'failed', 'login.failed.invalid'); }
// does the user has 2FA up ?
if (!isNullish(user.otp)) {
// yes -> we ask them to fill it,
// send them somehting to verify that they indeed passed throught the user+password phase
return makeResponse('otpRequired', 'login.otpRequired', { token: this.signJwt('otp', user.id) });
return res.makeResponse(200, 'otpRequired', 'login.otpRequired', { token: this.signJwt('otp', user.id) });
}
// every check has been passed, they are now logged in, using this token to say who they are...
return makeResponse('success', 'login.success', { token: this.signJwt('auth', user.id) });
return res.makeResponse(200, 'success', 'login.success', { token: this.signJwt('auth', user.id) });
}
catch {
return makeResponse('failed', 'login.failed.generic');
return res.makeResponse(500, 'failed', 'login.failed.generic');
}
},
);

View file

@ -1,9 +1,11 @@
import { typeResponse } from '@shared/utils';
import { FastifyPluginAsync } from 'fastify';
const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
void _opts;
fastify.post(
'/api/auth/logout',
{ schema: { response: { '200': typeResponse('success', 'logout.success') }, operationId: 'logout' } },
async function(_req, res) {
void _req;
return res.clearCookie('token').send('{}');

View file

@ -1,6 +1,6 @@
import { FastifyPluginAsync } from 'fastify';
import { Static, Type } from '@sinclair/typebox';
import { Static, Type } from 'typebox';
import { typeResponse, isNullish } from '@shared/utils';
import * as oauth2 from '../../oauth2';
@ -16,6 +16,7 @@ const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
void _opts;
fastify.get<{ Params: { provider?: string } }>(
'/api/auth/oauth2/:provider/callback',
{ schema: { hide: true } },
async function(req, res) {
const qs = (req.query as { [k: string]: string });
if (isNullish(req.params.provider) || !(req.params.provider in this.oauth2)) { return `provider '${req.params.provider ?? 'none'}' doesn't exist`; }

View file

@ -7,6 +7,7 @@ const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
void _opts;
fastify.get<{ Params: { provider?: string } }>(
'/api/auth/oauth2/:provider/login',
{ schema: { hide: true } },
async function(req, res) {
if (isNullish(req.params.provider) || !(req.params.provider in this.oauth2)) { return `provider '${req.params.provider ?? 'none'}' doesn't exist`; }
const provider = this.oauth2[req.params.provider];

View file

@ -1,8 +1,20 @@
// ************************************************************************** //
// //
// ::: :::::::: //
// otp.ts :+: :+: :+: //
// +:+ +:+ +:+ //
// By: maiboyer <maiboyer@student.42.fr> +#+ +:+ +#+ //
// +#+#+#+#+#+ +#+ //
// Created: 2025/11/07 16:25:58 by maiboyer #+# #+# //
// Updated: 2025/11/09 00:44:33 by maiboyer ### ########.fr //
// //
// ************************************************************************** //
import { FastifyPluginAsync } from 'fastify';
import { Static, Type } from '@sinclair/typebox';
import { Static, Type } from 'typebox';
import { JwtType, Otp } from '@shared/auth';
import { typeResponse, makeResponse, isNullish } from '@shared/utils';
import { typeResponse, MakeStaticResponse, isNullish } from '@shared/utils';
const OtpReq = Type.Object({
token: Type.String({ description: 'The token given at the login phase' }),
@ -11,12 +23,15 @@ const OtpReq = Type.Object({
type OtpReq = Static<typeof OtpReq>;
const OtpRes = Type.Union([
typeResponse('failed', ['otp.failed.generic', 'otp.failed.invalid', 'otp.failed.timeout', 'otp.failed.noSecret']),
typeResponse('success', 'otp.success', { token: Type.String({ description: 'the JWT Token' }) }),
]);
const OtpRes = {
'500': typeResponse('failed', 'otp.failed.generic'),
'400': typeResponse('failed', 'otp.failed.invalid'),
'401': typeResponse('failed', 'otp.failed.noSecret'),
'408': typeResponse('failed', 'otp.failed.timeout'),
'200': typeResponse('success', 'otp.success', { token: Type.String({ description: 'the JWT Token' }) }),
};
type OtpRes = Static<typeof OtpRes>;
type OtpRes = MakeStaticResponse<typeof OtpRes>;
const OTP_TOKEN_TIMEOUT_SEC = 120;
@ -24,9 +39,8 @@ const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
void _opts;
fastify.post<{ Body: OtpReq }>(
'/api/auth/otp',
{ schema: { body: OtpReq, response: { '2xx': OtpRes } } },
async function(req, _res) {
void _res;
{ schema: { body: OtpReq, response: OtpRes, operationId: 'loginOtp' } },
async function(req, res) {
try {
const { token, code } = req.body;
// lets try to decode+verify the jwt
@ -35,12 +49,12 @@ const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
// is the jwt a valid `otp` jwt ?
if (dJwt.kind != 'otp') {
// no ? fuck off then
return makeResponse('failed', 'otp.failed.invalid');
return res.makeResponse(400, 'failed', 'otp.failed.invalid');
}
// is it too old ?
if (dJwt.createdAt + OTP_TOKEN_TIMEOUT_SEC * 1000 < Date.now()) {
// yes ? fuck off then, redo the password
return makeResponse('failed', 'otp.failed.timeout');
return res.makeResponse(408, 'failed', 'otp.failed.timeout');
}
// get the Otp sercret from the db
@ -48,7 +62,7 @@ const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
if (isNullish(user?.otp)) {
// oops, either no user, or user without otpSecret
// fuck off
return makeResponse('failed', 'otp.failed.noSecret');
return res.makeResponse(401, 'failed', 'otp.failed.noSecret');
}
// good lets now verify the token you gave us is the correct one...
@ -66,13 +80,13 @@ const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
if (tokens.some((c) => c === code)) {
// they do !
// gg you are now logged in !
return makeResponse('success', 'otp.success', { token: this.signJwt('auth', dJwt.who) });
return res.makeResponse(200, 'success', 'otp.success', { token: this.signJwt('auth', dJwt.who) });
}
}
catch {
return makeResponse('failed', 'otp.failed.generic');
return res.makeResponse(500, 'failed', 'otp.failed.generic');
}
return makeResponse('failed', 'otp.failed.generic');
return res.makeResponse(500, 'failed', 'otp.failed.generic');
},
);
};

View file

@ -1,7 +1,7 @@
import { FastifyPluginAsync } from 'fastify';
import { Static, Type } from '@sinclair/typebox';
import { typeResponse, makeResponse, isNullish } from '@shared/utils';
import { Static, Type } from 'typebox';
import { typeResponse, isNullish, MakeStaticResponse } from '@shared/utils';
const USERNAME_CHECK: RegExp = /^[a-zA-Z_0-9]+$/;
@ -12,9 +12,10 @@ const SignInReq = Type.Object({
type SignInReq = Static<typeof SignInReq>;
const SignInRes = Type.Union([
typeResponse('failed', [
'signin.failed.generic',
const SignInRes = {
'500': typeResponse('failed',
'signin.failed.generic'),
'400': typeResponse('failed', [
'signin.failed.username.existing',
'signin.failed.username.toolong',
'signin.failed.username.tooshort',
@ -23,36 +24,35 @@ const SignInRes = Type.Union([
'signin.failed.password.tooshort',
'signin.failed.password.invalid',
]),
typeResponse('success', 'signin.success', { token: Type.String({ description: 'the JWT token' }) }),
]);
'200': typeResponse('success', 'signin.success', { token: Type.String({ description: 'the JWT token' }) }),
};
type SignInRes = Static<typeof SignInRes>;
type SignInRes = MakeStaticResponse<typeof SignInRes>;
const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
void _opts;
fastify.post<{ Body: SignInReq }>(
'/api/auth/signin',
{ schema: { body: SignInReq, response: { '200': SignInRes, '5xx': Type.Object({}) } } },
async function(req, _res) {
void _res;
{ schema: { body: SignInReq, response: SignInRes, operationId: 'signin' } },
async function(req, res) {
const { name, password } = req.body;
if (name.length < 4) {return makeResponse('failed', 'signin.failed.username.tooshort');}
if (name.length > 32) {return makeResponse('failed', 'signin.failed.username.toolong');}
if (!USERNAME_CHECK.test(name)) {return makeResponse('failed', 'signin.failed.username.invalid');}
if (name.length < 4) { return res.makeResponse(400, 'failed', 'signin.failed.username.tooshort'); }
if (name.length > 32) { return res.makeResponse(400, 'failed', 'signin.failed.username.toolong'); }
if (!USERNAME_CHECK.test(name)) { return res.makeResponse(400, 'failed', 'signin.failed.username.invalid'); }
// username if good now :)
if (password.length < 8) {return makeResponse('failed', 'signin.failed.password.tooshort');}
if (password.length > 64) {return makeResponse('failed', 'signin.failed.password.toolong');}
if (password.length < 8) { return res.makeResponse(400, 'failed', 'signin.failed.password.tooshort'); }
if (password.length > 64) { return res.makeResponse(400, 'failed', 'signin.failed.password.toolong'); }
// password is good too !
if (this.db.getUserFromLoginName(name) !== undefined) {return makeResponse('failed', 'signin.failed.username.existing');}
if (this.db.getUserFromLoginName(name) !== undefined) { return res.makeResponse(400, 'failed', 'signin.failed.username.existing'); }
const u = await this.db.createUser(name, name, password, false);
if (isNullish(u)) {return makeResponse('failed', 'signin.failed.generic');}
if (isNullish(u)) { return res.makeResponse(500, 'failed', 'signin.failed.generic'); }
// every check has been passed, they are now logged in, using this token to say who they are...
const userToken = this.signJwt('auth', u.id);
return makeResponse('success', 'signin.success', { token: userToken });
return res.makeResponse(200, 'success', 'signin.success', { token: userToken });
},
);
};

View file

@ -1,30 +1,31 @@
import { FastifyPluginAsync } from 'fastify';
import { Static, Type } from '@sinclair/typebox';
import { isNullish, makeResponse, typeResponse } from '@shared/utils';
import { Type } from 'typebox';
import { isNullish, MakeStaticResponse, typeResponse } from '@shared/utils';
import { Otp } from '@shared/auth';
export const StatusOtpRes = Type.Union([
typeResponse('success', 'statusOtp.success.enabled', { url: Type.String({ description: 'The otp url to feed into a 2fa app' }) }),
typeResponse('success', 'statusOtp.success.disabled'),
typeResponse('failure', 'statusOtp.failure.generic'),
]);
export const StatusOtpRes = {
200: Type.Union([
typeResponse('success', 'statusOtp.success.enabled', { url: Type.String({ description: 'The otp url to feed into a 2fa app' }) }),
typeResponse('success', 'statusOtp.success.disabled'),
]),
500: typeResponse('failure', 'statusOtp.failure.generic'),
};
export type StatusOtpRes = Static<typeof StatusOtpRes>;
export type StatusOtpRes = MakeStaticResponse<typeof StatusOtpRes>;
const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
void _opts;
fastify.get(
'/api/auth/statusOtp',
{ schema: { response: { '2xx': StatusOtpRes } }, config: { requireAuth: true } },
async function(req, _res) {
void _res;
if (isNullish(req.authUser)) {return makeResponse('failure', 'statusOtp.failure.generic');}
{ schema: { response: StatusOtpRes, operationId: 'statusOtp' }, config: { requireAuth: true } },
async function(req, res) {
if (isNullish(req.authUser)) { return res.makeResponse(500, 'failure', 'statusOtp.failure.generic'); }
const otpSecret = this.db.getUserOtpSecret(req.authUser.id);
if (isNullish(otpSecret)) {return makeResponse('success', 'statusOtp.success.disabled');}
if (isNullish(otpSecret)) { return res.makeResponse(200, 'success', 'statusOtp.success.disabled'); }
const otp = new Otp({ secret: otpSecret });
return makeResponse('success', 'statusOtp.success.enabled', { url: otp.totpURL });
return res.makeResponse(200, 'success', 'statusOtp.success.enabled', { url: otp.totpURL });
},
);
};

View file

@ -26,6 +26,9 @@ const externals = collectDeps(
export default defineConfig({
root: __dirname,
define: {
__SERVICE_NAME: '"auth"',
},
// service root
plugins: [tsconfigPaths(), nodeExternals()],
build: {
@ -33,7 +36,7 @@ export default defineConfig({
outDir: 'dist',
emptyOutDir: true,
lib: {
entry: path.resolve(__dirname, 'src/run.ts'),
entry: path.resolve(__dirname, process.env.VITE_ENTRYPOINT ?? 'src/run.ts'),
// adjust main entry
formats: ['cjs'],
// CommonJS for Node.js