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:
parent
1bd2b4594b
commit
b7c2a3dff9
36 changed files with 5472 additions and 833 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -10,3 +10,4 @@ node_modules/
|
|||
*.db-shm
|
||||
*.db-wal
|
||||
/db/
|
||||
package-lock.json
|
||||
|
|
|
|||
13
Makefile
13
Makefile
|
|
@ -6,7 +6,7 @@
|
|||
# By: rparodi <rparodi@student.42.fr> +#+ +:+ +#+ #
|
||||
# +#+#+#+#+#+ +#+ #
|
||||
# Created: 2023/11/12 11:05:05 by rparodi #+# #+# #
|
||||
# Updated: 2025/10/29 19:37:40 by maiboyer ### ########.fr #
|
||||
# Updated: 2025/11/09 01:22:55 by maiboyer ### ########.fr #
|
||||
# #
|
||||
# **************************************************************************** #
|
||||
|
||||
|
|
@ -123,6 +123,12 @@ npm@build:
|
|||
npm@update:
|
||||
(cd ./src/ && rm -rf ./src/node_modules/ && npx pnpm update -r --workspace)
|
||||
|
||||
npm@openapi:
|
||||
@(cd ./src/ && npx pnpm run --if-present -r build:openapi)
|
||||
@rm -f ./src/openapi.json
|
||||
@(cd ./src/ && npx pnpm exec redocly join --without-x-tag-groups)
|
||||
@(cd ./src/ && npx pnpm exec openapi-generator-cli generate -t ../openapi-template -g typescript-fetch -i openapi.json -o ../frontend/src/api/generated);
|
||||
|
||||
# this convert the .dbml file to an actual sql file that SQLite can handle :)
|
||||
sql:
|
||||
@echo "if the command isn't found, contact maieul :)"
|
||||
|
|
@ -138,5 +144,10 @@ tmux:
|
|||
@tmux select-window -t $(PROJECT):0
|
||||
@tmux attach-session -t $(PROJECT)
|
||||
|
||||
fnginx:
|
||||
fnginx &
|
||||
-(cd ./frontend && pnpm exec vite --clearScreen false)
|
||||
wait
|
||||
|
||||
# phony
|
||||
.PHONY: all clean fclean re header footer npm@install npm@clean npm@fclean npm@build sql tmux
|
||||
|
|
|
|||
32
package-lock.json
generated
32
package-lock.json
generated
|
|
@ -1,32 +0,0 @@
|
|||
{
|
||||
"name": "trans",
|
||||
"version": "0.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "trans",
|
||||
"version": "0.0.0",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"husky": "^9.1.7"
|
||||
}
|
||||
},
|
||||
"node_modules/husky": {
|
||||
"version": "9.1.7",
|
||||
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
|
||||
"integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"husky": "bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/typicode"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -11,7 +11,8 @@
|
|||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/jwt": "^9.1.0",
|
||||
"@sinclair/typebox": "^0.34.41",
|
||||
"@fastify/swagger": "^9.6.0",
|
||||
"@fastify/swagger-ui": "^5.2.3",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
|
|
@ -19,10 +20,11 @@
|
|||
"fastify-plugin": "^5.1.0",
|
||||
"joi": "^18.0.1",
|
||||
"otp": "^1.1.2",
|
||||
"typebox": "^1.0.51",
|
||||
"uuidv7": "^1.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^22.18.13"
|
||||
"@types/node": "^22.19.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@ import cookie from '@fastify/cookie';
|
|||
import fastifyJwt from '@fastify/jwt';
|
||||
import fp from 'fastify-plugin';
|
||||
import { FastifyPluginAsync, preValidationAsyncHookHandler } from 'fastify';
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { Static, TSchema, Type } from 'typebox';
|
||||
import * as Typebox from 'typebox';
|
||||
import { UserId } from '@shared/database/mixin/user';
|
||||
import { useDatabase } from '@shared/database';
|
||||
import { isNullish, makeResponse } from '@shared/utils';
|
||||
import { isNullish, typeResponse } from '@shared/utils';
|
||||
|
||||
const kRouteAuthDone = Symbol('shared-route-auth-done');
|
||||
|
||||
|
|
@ -53,11 +54,8 @@ export const jwtPlugin = fp<FastifyPluginAsync>(async (fastify, _opts) => {
|
|||
|
||||
export const JwtType = Type.Object({
|
||||
kind: Type.Union([
|
||||
Type.Const('otp', {
|
||||
description: 'the token is only valid for otp call',
|
||||
}),
|
||||
Type.Const('auth', {
|
||||
description: 'the token is valid for authentication',
|
||||
Type.Enum(['otp', 'auth'], {
|
||||
description: 'otp: token represent an inflight login request\nauth: represent a logged in user',
|
||||
}),
|
||||
]),
|
||||
who: Type.String({ description: 'the login of the user' }),
|
||||
|
|
@ -68,52 +66,64 @@ export const JwtType = Type.Object({
|
|||
|
||||
export type JwtType = Static<typeof JwtType>;
|
||||
|
||||
let authAdded = false;
|
||||
export const authPlugin = fp<FastifyPluginAsync>(async (fastify, _opts) => {
|
||||
void _opts;
|
||||
export const authSchema = typeResponse('notLoggedIn', ['auth.noCookie', 'auth.invalidKind', 'auth.noUser', 'auth.invalid']);
|
||||
|
||||
if (authAdded) return void console.log('skipping');
|
||||
let authAdded = false;
|
||||
export const authPlugin = fp<{ onlySchema?: boolean }>(async (fastify, { onlySchema }) => {
|
||||
|
||||
if (authAdded) return;
|
||||
const bOnlySchema = onlySchema ?? false;
|
||||
authAdded = true;
|
||||
if (!bOnlySchema) {
|
||||
await fastify.register(useDatabase as FastifyPluginAsync, {});
|
||||
await fastify.register(jwtPlugin as FastifyPluginAsync, {});
|
||||
await fastify.register(cookie);
|
||||
if (!fastify.hasRequestDecorator('authUser')) { fastify.decorateRequest('authUser', undefined); }
|
||||
}
|
||||
fastify.addHook('onRoute', (routeOpts) => {
|
||||
if (
|
||||
routeOpts.config?.requireAuth &&
|
||||
!routeOpts[kRouteAuthDone]
|
||||
) {
|
||||
routeOpts.schema = routeOpts.schema ?? {};
|
||||
routeOpts.schema.response = routeOpts.schema.response ?? {};
|
||||
let schema: TSchema = authSchema;
|
||||
if ('401' in (routeOpts.schema.response as { [k: string]: TSchema })) {
|
||||
const schema_orig = (routeOpts.schema.response as { [k: string]: TSchema })['401'];
|
||||
if (schema_orig[Typebox.Kind] === 'Union') {
|
||||
schema = Typebox.Union([...((schema_orig as Typebox.TUnion).anyOf), authSchema]);
|
||||
}
|
||||
else if (schema_orig[Typebox.Kind] === 'Object') {
|
||||
schema = Typebox.Union([schema_orig, authSchema]);
|
||||
}
|
||||
}
|
||||
(routeOpts.schema.response as { [k: string]: TSchema })['401'] = schema;
|
||||
if (!bOnlySchema) {
|
||||
const f: preValidationAsyncHookHandler = async function(req, res) {
|
||||
try {
|
||||
if (isNullish(req.cookies.token)) {
|
||||
return res
|
||||
.clearCookie('token')
|
||||
.send(
|
||||
JSON.stringify(makeResponse('notLoggedIn', 'auth.noCookie')),
|
||||
);
|
||||
.makeResponse(401, 'notLoggedIn', 'auth.noCookie');
|
||||
}
|
||||
const tok = this.jwt.verify<JwtType>(req.cookies.token);
|
||||
if (tok.kind != 'auth') {
|
||||
return res
|
||||
.clearCookie('token')
|
||||
.send(
|
||||
JSON.stringify(makeResponse('notLoggedIn', 'auth.invalidKind')),
|
||||
);
|
||||
.makeResponse(401, 'notLoggedIn', 'auth.invalidKind');
|
||||
}
|
||||
const user = this.db.getUser(tok.who);
|
||||
if (isNullish(user)) {
|
||||
return res
|
||||
.clearCookie('token')
|
||||
.send(
|
||||
JSON.stringify(makeResponse('notLoggedIn', 'auth.noUser')),
|
||||
);
|
||||
.makeResponse(401, 'notLoggedIn', 'auth.noUser');
|
||||
}
|
||||
req.authUser = { id: user.id, name: tok.who };
|
||||
}
|
||||
catch {
|
||||
return res
|
||||
.clearCookie('token')
|
||||
.send(JSON.stringify(makeResponse('notLoggedIn', 'auth.invalid')));
|
||||
.makeResponse(401, 'notLoggedIn', 'auth.invalid');
|
||||
}
|
||||
};
|
||||
if (!routeOpts.preValidation) {
|
||||
|
|
@ -125,6 +135,7 @@ export const authPlugin = fp<FastifyPluginAsync>(async (fastify, _opts) => {
|
|||
else {
|
||||
routeOpts.preValidation = [routeOpts.preValidation, f];
|
||||
}
|
||||
}
|
||||
|
||||
routeOpts[kRouteAuthDone] = true;
|
||||
}
|
||||
|
|
|
|||
26
src/@shared/src/swagger/index.ts
Normal file
26
src/@shared/src/swagger/index.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import fastifySwagger from '@fastify/swagger';
|
||||
import fastifySwaggerUi from '@fastify/swagger-ui';
|
||||
import fp from 'fastify-plugin';
|
||||
|
||||
export const useSwagger = fp(async (fastify, opts: { service: string }) => {
|
||||
await fastify.register(fastifySwagger, {
|
||||
openapi: {
|
||||
openapi: '3.1.0',
|
||||
servers: [
|
||||
{
|
||||
url: 'https://local.maix.me:8888',
|
||||
description: 'direct from docker',
|
||||
},
|
||||
{
|
||||
url: 'https://local.maix.me:8000',
|
||||
description: 'using fnginx',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await fastify.register(fastifySwaggerUi, {
|
||||
routePrefix: `/api/${opts.service}/documentation`,
|
||||
});
|
||||
});
|
||||
|
||||
export default useSwagger;
|
||||
|
|
@ -1,24 +1,34 @@
|
|||
import { TObject, TProperties, Type } from '@sinclair/typebox';
|
||||
import {
|
||||
Parameters,
|
||||
Static,
|
||||
TEnum,
|
||||
TSchema,
|
||||
Type,
|
||||
TProperties,
|
||||
TObject,
|
||||
} from 'typebox';
|
||||
import { FastifyReply } from 'fastify';
|
||||
import fp from 'fastify-plugin';
|
||||
|
||||
/**
|
||||
* @description Represent a message key
|
||||
* Used for translation of text, taken from a prebuilt dictionary
|
||||
* Format: `category.sub.desc`
|
||||
*
|
||||
* @example `login.failure.invalid`
|
||||
* @example `login.failure.missingPassword`
|
||||
* @example `login.failure.missingUser`
|
||||
* @example `signin.success`
|
||||
* @example `pong.you.lost`
|
||||
*/
|
||||
export type MessageKey = string;
|
||||
export type ResponseBase<T = object> = {
|
||||
kind: string,
|
||||
msg: MessageKey,
|
||||
payload?: T,
|
||||
const kMakeResponseSym = Symbol('make-response-sym');
|
||||
declare module 'fastify' {
|
||||
export interface RouteOptions {
|
||||
[kMakeResponseSym]: boolean;
|
||||
}
|
||||
}
|
||||
export const useMakeResponse = fp(async (fastify, opts) => {
|
||||
void opts;
|
||||
|
||||
/**
|
||||
fastify.decorateReply('makeResponse', makeResponse);
|
||||
});
|
||||
|
||||
export type MakeStaticResponse<T extends { [k: string]: TSchema }> = {
|
||||
[k in keyof T]: Static<T[k]>;
|
||||
};
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyReply {
|
||||
/**
|
||||
* @description Builds a response from a `kind`, `key` and an arbitrary payload
|
||||
*
|
||||
* * USE THIS FUNCTION TO ALLOW GREPING :) *
|
||||
|
|
@ -26,11 +36,24 @@ export type ResponseBase<T = object> = {
|
|||
* @example makeResponse("failure", "login.failure.invalid")
|
||||
* @example makeResponse("success", "login.success", { token: "supersecrettoken" })
|
||||
*/
|
||||
export function makeResponse<T = object>(kind: string, key: MessageKey, payload?: T): ResponseBase<T> {
|
||||
console.log(`making response {kind: ${JSON.stringify(kind)}; key: ${JSON.stringify(key)}}`);
|
||||
return { kind, msg: key, payload };
|
||||
makeResponse<T extends object>(
|
||||
status: Parameters<FastifyReply['code']>[0],
|
||||
kind: string,
|
||||
key: string,
|
||||
payload?: T,
|
||||
): ReturnType<FastifyReply['send']>;
|
||||
}
|
||||
}
|
||||
|
||||
function makeResponse<T extends object>(
|
||||
this: FastifyReply,
|
||||
status: Parameters<FastifyReply['code']>[0],
|
||||
kind: string,
|
||||
key: string,
|
||||
payload?: T,
|
||||
): ReturnType<FastifyReply['send']> {
|
||||
return this.code(status).send({ kind, msg: key, payload });
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Create a typebox Type for a response.
|
||||
|
|
@ -39,20 +62,60 @@ export function makeResponse<T = object>(kind: string, key: MessageKey, payload?
|
|||
* @example typeResponse("otpRequired", "login.otpRequired", { token: Type.String() })
|
||||
* @example typeResponse("success", "login.success", { token: Type.String() })
|
||||
*/
|
||||
export function typeResponse(kind: string, key: MessageKey | MessageKey[], payload?: TProperties): TObject<TProperties> {
|
||||
let tKey;
|
||||
if (key instanceof Array) {
|
||||
tKey = Type.Union(key.map(l => Type.Const(l)));
|
||||
}
|
||||
else {
|
||||
tKey = Type.Const(key);
|
||||
}
|
||||
export function typeResponse<K extends string, M extends string>(
|
||||
kind: K,
|
||||
key: M,
|
||||
): TObject<{
|
||||
kind: TEnum<[K]>;
|
||||
msg: TEnum<M[]>;
|
||||
}>;
|
||||
export function typeResponse<K extends string, M extends string[]>(
|
||||
kind: K,
|
||||
key: [...M],
|
||||
): TObject<{
|
||||
kind: TEnum<K[]>;
|
||||
msg: TEnum<M>;
|
||||
}>;
|
||||
export function typeResponse<
|
||||
K extends string,
|
||||
M extends string,
|
||||
T extends TProperties,
|
||||
>(
|
||||
kind: K,
|
||||
key: M,
|
||||
payload: T,
|
||||
): TObject<{
|
||||
kind: TEnum<[K]>;
|
||||
msg: TEnum<M[]>;
|
||||
payload: TObject<T>;
|
||||
}>;
|
||||
export function typeResponse<
|
||||
K extends string,
|
||||
M extends string[],
|
||||
T extends TProperties,
|
||||
>(
|
||||
kind: K,
|
||||
key: [...M],
|
||||
payload: T,
|
||||
): TObject<{
|
||||
kind: TEnum<[K]>;
|
||||
msg: TEnum<M>;
|
||||
payload: TObject<T>;
|
||||
}>;
|
||||
export function typeResponse<K extends string, T extends TProperties>(
|
||||
kind: K,
|
||||
key: unknown,
|
||||
payload?: T,
|
||||
): unknown {
|
||||
const tKey = Type.Enum(Array.isArray(key) ? key : [key]);
|
||||
|
||||
const Ty = {
|
||||
kind: Type.Const(kind),
|
||||
kind: Type.Enum([kind]),
|
||||
msg: tKey,
|
||||
};
|
||||
if (payload !== undefined) {Object.assign(Ty, { payload: Type.Object(payload) });}
|
||||
if (payload !== undefined) {
|
||||
Object.assign(Ty, { payload: Type.Object(payload) });
|
||||
}
|
||||
|
||||
return Type.Object(Ty);
|
||||
}
|
||||
|
|
@ -68,6 +131,6 @@ export function typeResponse(kind: string, key: MessageKey | MessageKey[], paylo
|
|||
* @example assert_equal(isNullish({}), false);
|
||||
* @example assert_equal(isNullish(false), false);
|
||||
*/
|
||||
export function isNullish<T>(v: T | undefined | null): v is (null | undefined) {
|
||||
export function isNullish<T>(v: T | undefined | null): v is null | undefined {
|
||||
return v === null || v === undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,16 @@ FROM node:22-alpine AS pnpm_base
|
|||
RUN npm install --global pnpm@10 --no-fund -q;
|
||||
RUN apk add make python3 gcc clang build-base musl-dev;
|
||||
|
||||
FROM pnpm_base AS deps
|
||||
WORKDIR /build
|
||||
ARG SERVICE
|
||||
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml /build/
|
||||
COPY @shared/package.json /build/@shared/
|
||||
COPY ${SERVICE}/package.json /build/${SERVICE}/
|
||||
|
||||
RUN pnpm install --frozen-lockfile;
|
||||
|
||||
FROM pnpm_base AS builder
|
||||
|
||||
ARG SERVICE
|
||||
|
|
@ -13,7 +23,7 @@ COPY ${SERVICE}/ /build/${SERVICE}
|
|||
COPY tsconfig.base.json pnpm-workspace.yaml pnpm-lock.yaml /build/
|
||||
COPY ${SERVICE}/entrypoint.sh /build/
|
||||
|
||||
RUN pnpm install --frozen-lockfile;
|
||||
COPY --from=deps /build/node_modules /build/node_modules
|
||||
|
||||
COPY @shared/ /build/@shared/
|
||||
COPY ${SERVICE}/ /build/${SERVICE}/
|
||||
|
|
@ -35,7 +45,8 @@ WORKDIR /src
|
|||
|
||||
ARG EXTRA_FILES=empty
|
||||
COPY --from=builder /dist /src
|
||||
RUN pnpm install --prod --frozen-lockfile;
|
||||
COPY --from=deps /build/node_modules /src/node_modules
|
||||
|
||||
COPY ${EXTRA_FILES} /extra
|
||||
ENTRYPOINT [ "/src/entrypoint.sh" ]
|
||||
|
||||
|
|
|
|||
849
src/auth/openapi.json
Normal file
849
src/auth/openapi.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, {});
|
||||
|
|
|
|||
|
|
@ -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
21
src/auth/src/openapi.ts
Normal 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();
|
||||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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('{}');
|
||||
|
|
|
|||
|
|
@ -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`; }
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
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'),
|
||||
typeResponse('failure', 'statusOtp.failure.generic'),
|
||||
]);
|
||||
]),
|
||||
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 });
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -23,15 +23,15 @@
|
|||
"@fastify/sensible": "^6.0.3",
|
||||
"@fastify/static": "^8.3.0",
|
||||
"fastify": "^5.6.1",
|
||||
"fastify-cli": "^7.4.0",
|
||||
"fastify-cli": "^7.4.1",
|
||||
"fastify-plugin": "^5.1.0",
|
||||
"raw-body": "^3.0.1",
|
||||
"sharp": "^0.34.4"
|
||||
"sharp": "^0.34.5"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1038
src/openapi.json
Normal file
1038
src/openapi.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -24,16 +24,20 @@
|
|||
"dev:prepare": "husky"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.38.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.46.2",
|
||||
"@typescript-eslint/parser": "^8.46.2",
|
||||
"eslint": "^9.38.0",
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@openapitools/openapi-generator-cli": "^2.25.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.46.3",
|
||||
"@typescript-eslint/parser": "^8.46.3",
|
||||
"eslint": "^9.39.1",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.2.6",
|
||||
"openapi-typescript": "^7.10.1",
|
||||
"rimraf": "^5.0.10",
|
||||
"typescript-eslint": "^8.46.2"
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.46.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@redocly/cli": "^2.11.0",
|
||||
"bindings": "^1.5.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3438
src/pnpm-lock.yaml
generated
3438
src/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
43
src/redocly.yaml
Normal file
43
src/redocly.yaml
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
apis:
|
||||
auth:
|
||||
root: ./auth/openapi.json
|
||||
user:
|
||||
root: ./user/openapi.json
|
||||
|
||||
rules:
|
||||
info-license: warn
|
||||
info-license-strict: warn
|
||||
no-ambiguous-paths: warn
|
||||
no-duplicated-tag-names: warn
|
||||
no-empty-servers: error
|
||||
no-enum-type-mismatch: error
|
||||
no-example-value-and-externalValue: error
|
||||
no-identical-paths: error
|
||||
no-invalid-media-type-examples: warn
|
||||
no-invalid-parameter-examples: warn
|
||||
no-invalid-schema-examples: warn
|
||||
no-path-trailing-slash: error
|
||||
no-required-schema-properties-undefined: warn
|
||||
no-schema-type-mismatch: error
|
||||
no-server-example.com: warn
|
||||
no-server-trailing-slash: error
|
||||
no-server-variables-empty-enum: error
|
||||
no-unresolved-refs: error
|
||||
no-unused-components: warn
|
||||
no-undefined-server-variable: error
|
||||
operation-2xx-response: warn
|
||||
operation-4xx-response: warn
|
||||
operation-operationId: warn
|
||||
operation-operationId-unique: error
|
||||
operation-operationId-url-safe: error
|
||||
operation-parameters-unique: error
|
||||
operation-summary: error
|
||||
path-declaration-must-exist: error
|
||||
path-not-include-query: error
|
||||
path-parameters-defined: error
|
||||
path-params-defined: error
|
||||
security-defined: error
|
||||
spec-components-invalid-map-name: error
|
||||
struct: error
|
||||
tag-description: warn
|
||||
|
||||
178
src/user/openapi.json
Normal file
178
src/user/openapi.json
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"version": "9.6.0",
|
||||
"title": "@fastify/swagger"
|
||||
},
|
||||
"components": {
|
||||
"schemas": {}
|
||||
},
|
||||
"paths": {
|
||||
"/api/user/info/{user}": {
|
||||
"get": {
|
||||
"operationId": "getUser",
|
||||
"parameters": [
|
||||
{
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"enum": [
|
||||
"me"
|
||||
],
|
||||
"description": "the current logged in user"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "A user uuid"
|
||||
}
|
||||
]
|
||||
},
|
||||
"in": "path",
|
||||
"name": "user",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Default Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"msg",
|
||||
"payload"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"enum": [
|
||||
"success"
|
||||
]
|
||||
},
|
||||
"msg": {
|
||||
"enum": [
|
||||
"userinfo.success"
|
||||
]
|
||||
},
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"id",
|
||||
"guest"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"guest": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Default Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"msg"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"enum": [
|
||||
"failure"
|
||||
]
|
||||
},
|
||||
"msg": {
|
||||
"enum": [
|
||||
"userinfo.failure.notLoggedIn"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Default Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"msg"
|
||||
],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"enum": [
|
||||
"failure"
|
||||
]
|
||||
},
|
||||
"msg": {
|
||||
"enum": [
|
||||
"userinfo.failure.unknownUser"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "https://local.maix.me:8888",
|
||||
"description": "direct from docker"
|
||||
},
|
||||
{
|
||||
"url": "https://local.maix.me:8000",
|
||||
"description": "using fnginx"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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,15 +23,15 @@
|
|||
"@fastify/multipart": "^9.3.0",
|
||||
"@fastify/sensible": "^6.0.3",
|
||||
"@fastify/static": "^8.3.0",
|
||||
"@sinclair/typebox": "^0.34.41",
|
||||
"typebox": "^1.0.51",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,21 +3,20 @@ 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 });
|
||||
// @ts-expect-error: import.meta.glob is a vite thing. Typescript doesn't know this...
|
||||
const routes = import.meta.glob('./routes/**/*.ts', { eager: true });
|
||||
|
||||
// When using .decorate you have to specify added properties for Typescript
|
||||
declare module 'fastify' {
|
||||
export interface FastifyInstance {
|
||||
image_store: string;
|
||||
}
|
||||
}
|
||||
|
||||
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, {});
|
||||
|
|
|
|||
21
src/user/src/openapi.ts
Normal file
21
src/user/src/openapi.ts
Normal 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();
|
||||
|
|
@ -1,26 +1,37 @@
|
|||
import { FastifyPluginAsync } from 'fastify';
|
||||
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { isNullish, makeResponse, typeResponse } from '@shared/utils';
|
||||
import { Static, Type } from 'typebox';
|
||||
import { isNullish, MakeStaticResponse, typeResponse } from '@shared/utils';
|
||||
|
||||
|
||||
export const UserInfoRes = Type.Union([
|
||||
typeResponse('success', 'userinfo.success', { name: Type.String(), id: Type.String(), guest: Type.Boolean() }),
|
||||
typeResponse('failure', ['userinfo.failure.generic', 'userinfo.failure.unknownUser', 'userinfo.failure.notLoggedIn']),
|
||||
]);
|
||||
export const UserInfoRes = {
|
||||
'200': typeResponse('success', 'userinfo.success', {
|
||||
name: Type.String(), id: Type.String(), guest: Type.Boolean(),
|
||||
}),
|
||||
'403': typeResponse('failure', 'userinfo.failure.notLoggedIn'),
|
||||
'404': typeResponse('failure', 'userinfo.failure.unknownUser'),
|
||||
};
|
||||
|
||||
export type UserInfoRes = Static<typeof UserInfoRes>;
|
||||
export type UserInfoRes = MakeStaticResponse<typeof UserInfoRes>;
|
||||
|
||||
export const UserInfoParams = Type.Object({
|
||||
user: Type.Union([
|
||||
Type.Enum(['me'], { description: 'the current logged in user' }),
|
||||
Type.String({ format: 'uuid', description: 'A user uuid' }),
|
||||
]),
|
||||
});
|
||||
|
||||
export type UserInfoParams = Static<typeof UserInfoParams>;
|
||||
|
||||
const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
|
||||
void _opts;
|
||||
fastify.get<{ Params: { user: string } }>(
|
||||
fastify.get<{ Params: UserInfoParams }>(
|
||||
'/api/user/info/:user',
|
||||
{ schema: { response: { '2xx': UserInfoRes } }, config: { requireAuth: true } },
|
||||
async function(req, _res) {
|
||||
void _res;
|
||||
if (isNullish(req.authUser)) { return makeResponse('failure', 'userinfo.failure.notLoggedIn'); }
|
||||
{ schema: { params: UserInfoParams, response: UserInfoRes, operationId: 'getUser' }, config: { requireAuth: true } },
|
||||
async function(req, res) {
|
||||
if (isNullish(req.authUser)) { return res.makeResponse(403, 'failure', 'userinfo.failure.notLoggedIn'); }
|
||||
if (isNullish(req.params.user) || req.params.user.length === 0) {
|
||||
return makeResponse('failure', 'userinfo.failure.unknownUser');
|
||||
return res.makeResponse(404, 'failure', 'userinfo.failure.unknownUser');
|
||||
}
|
||||
|
||||
// if the param is the special value `me`, then just get the id from the currently auth'ed user
|
||||
|
|
@ -30,7 +41,7 @@ const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
|
|||
|
||||
const user = this.db.getUser(req.params.user);
|
||||
if (isNullish(user)) {
|
||||
return makeResponse('failure', 'userinfo.failure.unknownUser');
|
||||
return res.makeResponse(404, 'failure', 'userinfo.failure.unknownUser');
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -48,7 +59,7 @@ const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
|
|||
guest: !!user.guest,
|
||||
};
|
||||
|
||||
return makeResponse('success', 'userinfo.success', payload);
|
||||
return res.makeResponse(200, 'success', 'userinfo.success', payload);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ const externals = collectDeps(
|
|||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
define: {
|
||||
__SERVICE_NAME: '"user"',
|
||||
},
|
||||
// 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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue