feat(frontend/auth): Added way to automatically fetch providers

This allows the frontend to list all available providers without
hardcoding them in.
This commit is contained in:
Maieul BOYER 2025-11-13 16:53:24 +01:00 committed by Maix0
parent 9ce9fa44e4
commit 6d630fee92
22 changed files with 1448 additions and 221 deletions

View file

@ -1,186 +1 @@
{
"type": "object",
"properties": {
"providers": {
"type": "object",
"patternProperties": {
"^(.*)$": {
"anyOf": [
{
"type": "object",
"properties": {
"token_url": {
"type": "string"
},
"auth_url": {
"type": "string"
},
"info_url": {
"type": "string"
},
"client_id": {
"type": "string"
},
"client_secret": {
"anyOf": [
{
"type": "object",
"properties": {
"env": {
"description": "Secret is stored in the env var",
"type": "string"
}
},
"required": [
"env"
]
},
{
"type": "object",
"properties": {
"inline": {
"description": "Secret is inline here",
"type": "string"
}
},
"required": [
"inline"
]
}
]
},
"scopes": {
"type": "array",
"items": {
"type": "string"
}
},
"redirect_url": {
"type": "string"
},
"user": {
"default": {
"unique_id": "email",
"name": "name"
},
"type": "object",
"properties": {
"unique_id": {
"description": "A unique identifier for this provider",
"default": "email",
"type": "string"
},
"name": {
"description": "A name for this provider",
"default": "name",
"type": "string"
}
},
"required": [
"unique_id",
"name"
]
}
},
"required": [
"token_url",
"auth_url",
"info_url",
"client_id",
"client_secret",
"scopes",
"redirect_url",
"user"
]
},
{
"type": "object",
"properties": {
"openid_url": {
"type": "string"
},
"client_id": {
"type": "string"
},
"client_secret": {
"anyOf": [
{
"type": "object",
"properties": {
"env": {
"description": "Secret is stored in the env var",
"type": "string"
}
},
"required": [
"env"
]
},
{
"type": "object",
"properties": {
"inline": {
"description": "Secret is inline here",
"type": "string"
}
},
"required": [
"inline"
]
}
]
},
"scopes": {
"type": "array",
"items": {
"type": "string"
}
},
"redirect_url": {
"type": "string"
},
"user": {
"default": {
"unique_id": "email",
"name": "name"
},
"type": "object",
"properties": {
"unique_id": {
"description": "A unique identifier for this provider",
"default": "email",
"type": "string"
},
"name": {
"description": "A name for this provider",
"default": "name",
"type": "string"
}
},
"required": [
"unique_id",
"name"
]
}
},
"required": [
"openid_url",
"client_id",
"client_secret",
"scopes",
"redirect_url",
"user"
]
}
]
}
}
},
"$schema": {
"type": "string"
}
},
"required": [
"providers"
]
}
{"type":"object","required":["providers"],"properties":{"providers":{"type":"object","patternProperties":{"^.*$":{"anyOf":[{"type":"object","required":["token_url","auth_url","info_url","client_id","client_secret","scopes","redirect_url","user","display_name"],"properties":{"token_url":{"type":"string"},"auth_url":{"type":"string"},"info_url":{"type":"string"},"client_id":{"type":"string"},"client_secret":{"anyOf":[{"type":"object","required":["env"],"properties":{"env":{"type":"string","description":"Secret is stored in the env var"}}},{"type":"object","required":["inline"],"properties":{"inline":{"type":"string","description":"Secret is inline here"}}}]},"scopes":{"type":"array","items":{"type":"string"}},"redirect_url":{"type":"string"},"user":{"type":"object","required":["unique_id","name"],"properties":{"unique_id":{"type":"string","description":"A unique identifier for this provider","default":"email"},"name":{"type":"string","description":"A name for this provider","default":"name"}},"default":{"unique_id":"email","name":"name"}},"display_name":{"type":"string"},"color":{"type":"object","properties":{"default":{"type":"string"},"hover":{"type":"string"}}}}},{"type":"object","required":["openid_url","client_id","client_secret","scopes","redirect_url","user","display_name"],"properties":{"openid_url":{"type":"string"},"client_id":{"type":"string"},"client_secret":{"anyOf":[{"type":"object","required":["env"],"properties":{"env":{"type":"string","description":"Secret is stored in the env var"}}},{"type":"object","required":["inline"],"properties":{"inline":{"type":"string","description":"Secret is inline here"}}}]},"scopes":{"type":"array","items":{"type":"string"}},"redirect_url":{"type":"string"},"user":{"type":"object","required":["unique_id","name"],"properties":{"unique_id":{"type":"string","description":"A unique identifier for this provider","default":"email"},"name":{"type":"string","description":"A name for this provider","default":"name"}},"default":{"unique_id":"email","name":"name"}},"display_name":{"type":"string"},"color":{"type":"object","properties":{"default":{"type":"string"},"hover":{"type":"string"}}}}}]}}},"$schema":{"type":"string"}}}

View file

@ -9,6 +9,7 @@ scopes = ["any needed scope here", "openid", "email"]
redirect_url = "https://local.maix.me:8888/api/auth/oauth2/provider-openid/callback"
# from the `info_url` request, which json key we will take an unique provider id (default:email) and an name for the user (default:name)
user = { unique_id = "email", name = "name" }
display_name = "OpenID 1"
[providers.discord]
auth_url = "https://discord.com/oauth2/authorize"
@ -19,3 +20,4 @@ client_id = "CLIENT_ID"
redirect_url = "https://local.maix.me:8888/api/auth/oauth2/discord/callback"
scopes = ["identify"] # here no email asked :)
user = { unique_id = "id", name = "username" } # for example discord provides some stuff, like unique_id and username, such that we dont have to ask additional permission to get the email
display_name = "Discord"

View file

@ -195,6 +195,86 @@
}
}
},
"/api/auth/providerList": {
"get": {
"operationId": "providerList",
"responses": {
"200": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg",
"payload"
],
"properties": {
"kind": {
"enum": [
"success"
]
},
"msg": {
"enum": [
"providerList.success"
]
},
"payload": {
"type": "object",
"required": [
"list"
],
"properties": {
"list": {
"type": "array",
"items": {
"type": "object",
"required": [
"display_name",
"name",
"colors"
],
"properties": {
"display_name": {
"type": "string",
"description": "Name to display to the user"
},
"name": {
"type": "string",
"description": "internal Name of the provider"
},
"colors": {
"type": "object",
"required": [
"normal",
"hover"
],
"properties": {
"normal": {
"type": "string",
"description": "Default color for the provider"
},
"hover": {
"type": "string",
"description": "Hover color for the provider"
}
}
}
}
}
}
}
}
}
}
}
}
}
}
}
},
"/api/auth/guest": {
"post": {
"operationId": "guestLogin",

View file

@ -1,19 +1,11 @@
import { isNullish } from '@shared/utils';
import fp from 'fastify-plugin';
import { readFile } from 'node:fs/promises';
import { access, constants as fsConstants, readFile } from 'node:fs/promises';
import * as T from 'typebox';
import * as V from 'typebox/value';
import { Oauth2 } from '../oauth2';
import { parseTOML } from 'confbox';
/*
function isNullish<T>(_v: T): boolean { return true; }
class Oauth2 {
constructor(..._args: any[]) { }
static fromProvider(..._args: any[]): Oauth2 { throw 'yes'; }
}
*/
const ProviderSecret = T.Union([
T.Object({
env: T.String({ description: 'Secret is stored in the env var' }),
@ -21,10 +13,19 @@ const ProviderSecret = T.Union([
T.Object({ inline: T.String({ description: 'Secret is inline here' }) }),
]);
const ProviderUserInfo = T.Object({
unique_id: T.String({ description: 'A unique identifier for this provider', default: 'email' }),
name: T.String({ description: 'A name for this provider', default: 'name' }),
}, { default: { unique_id: 'email', name: 'name' } });
const ProviderUserInfo = T.Object(
{
unique_id: T.String({
description: 'A unique identifier for this provider',
default: 'email',
}),
name: T.String({
description: 'A name for this provider',
default: 'name',
}),
},
{ default: { unique_id: 'email', name: 'name' } },
);
const RawProviderBase = {
client_id: T.String(),
@ -32,6 +33,13 @@ const RawProviderBase = {
scopes: T.Array(T.String()),
redirect_url: T.String(),
user: ProviderUserInfo,
display_name: T.String(),
color: T.Optional(
T.Object({
default: T.Optional(T.String()),
hover: T.Optional(T.String()),
}),
),
};
const ProviderBase = T.Object(RawProviderBase);
@ -49,6 +57,8 @@ const ProviderMapFile = T.Object({
$schema: T.Optional(T.String()),
});
// console.log(JSON.stringify(ProviderMapFile))
export type ProviderSecret = T.Static<typeof ProviderSecret>;
export type ProviderUserInfo = T.Static<typeof ProviderUserInfo>;
export type ProviderBase = T.Static<typeof ProviderBase>;
@ -58,10 +68,15 @@ export type Provider = T.Static<typeof Provider>;
export type ProviderMap = T.Static<typeof ProviderMap>;
export type ProviderMapFile = T.Static<typeof ProviderMapFile>;
async function buildProviderMap(): Promise<ProviderMap> {
const providerFile = process.env.PROVIDER_FILE;
if (isNullish(providerFile)) throw 'PROVIDER_FILE env var not provided';
if (isNullish(providerFile)) return {};
try {
await access(providerFile, fsConstants.F_OK | fsConstants.R_OK);
}
catch {
return {};
}
const data = await readFile(providerFile, { encoding: 'utf-8' });
const dataJson = parseTOML(data);
return V.Parse(ProviderMapFile, dataJson).providers;
@ -73,7 +88,9 @@ declare module 'fastify' {
oauth2: { [k: string]: Oauth2 };
}
}
async function makeAllOauth2(providers: ProviderMap): Promise<{ [k: string]: Oauth2 }> {
async function makeAllOauth2(
providers: ProviderMap,
): Promise<{ [k: string]: Oauth2 }> {
const out: { [k: string]: Oauth2 } = {};
for (const [k, v] of Object.entries(providers)) {
out[k] = await Oauth2.fromProvider(k, v);

View file

@ -0,0 +1,46 @@
import { FastifyPluginAsync } from 'fastify';
import { Type } from 'typebox';
import { typeResponse, MakeStaticResponse } from '@shared/utils';
export const ProviderListRes = {
'200': typeResponse('success', 'providerList.success', {
list: Type.Array(Type.Object({
display_name: Type.String({ description: 'Name to display to the user' }),
name: Type.String({ description: 'internal Name of the provider' }),
colors: Type.Object({
normal: Type.String({ description: 'Default color for the provider' }),
hover: Type.String({ description: 'Hover color for the provider' }),
}),
})),
}),
};
export type ProviderListRes = MakeStaticResponse<typeof ProviderListRes>;
const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
void _opts;
fastify.get<{ Reply: ProviderListRes }>(
'/api/auth/providerList',
{ schema: { response: ProviderListRes, operationId: 'providerList' } },
async function(req, res) {
void req;
const list = Object.entries(this.providers).map(([providerName, provider]) => {
const colors = provider.color ?? {};
return {
display_name: provider.display_name,
name: providerName,
colors: {
normal: colors.default ?? 'bg-blue-600',
hover: colors.hover ?? 'bg-blue-700',
},
};
});
return res.makeResponse(200, 'success', 'providerList.success', { list });
},
);
};
export default route;

View file

@ -18,7 +18,7 @@ const route: FastifyPluginAsync = async (fastify, _opts): Promise<void> => {
const [url, _csrf, _nonce] = u.intoUrl();
void _csrf; void _nonce;
return res.setCookie('pkce', verifier.secret).redirect(url.toString());
return res.setCookie('pkce', verifier.secret, { path:'/' }).redirect(url.toString());
},
);
};