From d3da1cf2e1b5b18fe6b2581aef515546f78fba09 Mon Sep 17 00:00:00 2001 From: riccardoperra Date: Sun, 17 Dec 2023 17:35:03 +0100 Subject: [PATCH 01/16] feat: add hanko api passkey integration --- apps/api/api-types/index.d.ts | 4 + apps/api/package.json | 4 + apps/api/src/app.ts | 8 ++ apps/api/src/common/typebox/nullable.ts | 26 +++++- apps/api/src/plugins/auth0.ts | 81 ++++++++----------- apps/api/src/plugins/auth0Management.ts | 20 +++++ apps/api/src/plugins/multi-auth.ts | 30 +++++++ apps/api/src/plugins/passkeys.ts | 56 +++++++++++++ apps/api/src/plugins/user.ts | 6 ++ .../src/routes/v1/passkey/deleteCredential.ts | 25 ++++++ .../src/routes/v1/passkey/finalizeLogin.ts | 70 ++++++++++++++++ .../routes/v1/passkey/finalizeRegistration.ts | 75 +++++++++++++++++ .../src/routes/v1/passkey/listCredentials.ts | 27 +++++++ apps/api/src/routes/v1/passkey/startLogin.ts | 38 +++++++++ .../routes/v1/passkey/startRegistration.ts | 80 ++++++++++++++++++ apps/api/src/routes/v1/preset/create.ts | 17 ++-- apps/api/src/routes/v1/preset/delete.ts | 29 +++---- apps/api/src/routes/v1/preset/getAll.ts | 17 ++-- apps/api/src/routes/v1/preset/getById.ts | 5 +- apps/api/src/routes/v1/preset/update.ts | 25 +++--- apps/api/src/routes/v1/project/clone.ts | 25 +++--- apps/api/src/routes/v1/project/create.ts | 17 ++-- apps/api/src/routes/v1/project/delete.ts | 29 +++---- .../src/routes/v1/project/getAllByUserId.ts | 2 +- apps/api/src/routes/v1/project/getById.ts | 26 +++--- apps/api/src/routes/v1/project/update.ts | 25 +++--- apps/api/src/routes/v1/project/updateName.ts | 34 ++++---- apps/api/src/routes/v1/user/info.ts | 17 ++++ apps/api/src/schemas/index.ts | 5 ++ 29 files changed, 629 insertions(+), 194 deletions(-) create mode 100644 apps/api/src/plugins/auth0Management.ts create mode 100644 apps/api/src/plugins/multi-auth.ts create mode 100644 apps/api/src/plugins/passkeys.ts create mode 100644 apps/api/src/plugins/user.ts create mode 100644 apps/api/src/routes/v1/passkey/deleteCredential.ts create mode 100644 apps/api/src/routes/v1/passkey/finalizeLogin.ts create mode 100644 apps/api/src/routes/v1/passkey/finalizeRegistration.ts create mode 100644 apps/api/src/routes/v1/passkey/listCredentials.ts create mode 100644 apps/api/src/routes/v1/passkey/startLogin.ts create mode 100644 apps/api/src/routes/v1/passkey/startRegistration.ts create mode 100644 apps/api/src/routes/v1/user/info.ts diff --git a/apps/api/api-types/index.d.ts b/apps/api/api-types/index.d.ts index 6767ab956..15673f64a 100644 --- a/apps/api/api-types/index.d.ts +++ b/apps/api/api-types/index.d.ts @@ -10,4 +10,8 @@ export type { GetPresetByIdApi, UpdatePresetApi, GetAllPresetApi, + PasskeyStartRegistrationApi, + PasskeyFinalizeRegistrationApi, + PasskeyStartLoginApi, + PasskeyFinalizeLoginApi, } from '../dist/schemas/index.js'; diff --git a/apps/api/package.json b/apps/api/package.json index 5c08064b5..2e9c73d3d 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -40,6 +40,7 @@ "license": "ISC", "dependencies": { "@codeimage/prisma-models": "workspace:*", + "@fastify/auth": "4.4.0", "@fastify/autoload": "^5.7.1", "@fastify/cors": "^8.3.0", "@fastify/env": "^4.2.0", @@ -50,6 +51,8 @@ "@fastify/type-provider-typebox": "^3.2.0", "@prisma/client": "^4.15.0", "@sinclair/typebox": "^0.28.15", + "@teamhanko/passkeys-sdk": "0.1.8", + "auth0": "4.2.0", "close-with-grace": "^1.2.0", "dotenv": "^16.1.4", "dotenv-cli": "^6.0.0", @@ -57,6 +60,7 @@ "fastify-auth0-verify": "^1.2.0", "fastify-cli": "^5.7.1", "fastify-healthcheck": "^4.4.0", + "fastify-jwt-jwks": "1.1.4", "fastify-plugin": "^4.5.0", "fluent-json-schema": "^4.1.0", "prisma": "^4.15.0" diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 8edc3290b..6ce990b15 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -2,6 +2,7 @@ import AutoLoad, {AutoloadPluginOptions} from '@fastify/autoload'; import fastifyEnv from '@fastify/env'; import {Type} from '@sinclair/typebox'; import {FastifyPluginAsync} from 'fastify'; +import fp from 'fastify-plugin'; import path, {join} from 'node:path'; import {fileURLToPath} from 'node:url'; @@ -20,6 +21,9 @@ declare module 'fastify' { GRANT_TYPE_AUTH0?: string; ALLOWED_ORIGINS?: string; PRESETS_LIMIT?: number; + HANKO_PASSKEYS_LOGIN_BASE_URL: string; + HANKO_PASSKEYS_TENANT_ID: string; + HANKO_PASSKEYS_API_KEY: string; }; } } @@ -51,6 +55,9 @@ const app: FastifyPluginAsync = async ( GRANT_TYPE_AUTH0: Type.String(), ALLOWED_ORIGINS: Type.String(), PRESETS_LIMIT: Type.Number({default: Number.MAX_SAFE_INTEGER}), + HANKO_PASSKEYS_LOGIN_BASE_URL: Type.String(), + HANKO_PASSKEYS_TENANT_ID: Type.String(), + HANKO_PASSKEYS_API_KEY: Type.String(), }), }); @@ -61,6 +68,7 @@ const app: FastifyPluginAsync = async ( dir: join(__dirname, 'plugins'), options: opts, forceESM: true, + encapsulate: false, }); // This loads all plugins defined in routes diff --git a/apps/api/src/common/typebox/nullable.ts b/apps/api/src/common/typebox/nullable.ts index bfca48688..b7dd7a90a 100644 --- a/apps/api/src/common/typebox/nullable.ts +++ b/apps/api/src/common/typebox/nullable.ts @@ -1,14 +1,32 @@ -import {SchemaOptions, TSchema, Type} from '@sinclair/typebox'; +import { + SchemaOptions, + TNull, + TOptional, + TSchema, + TUnion, + Type, +} from '@sinclair/typebox'; -export const Nullable = (tType: T, optional = true) => { +export function Nullable( + tType: T, + optional?: true, +): TOptional>; +export function Nullable( + tType: T, + optional?: false, +): TUnion<[T, TNull]>; +export function Nullable( + tType: T, + optional?: boolean, +): TOptional> | TUnion<[T, TNull]> { const options: SchemaOptions | undefined = Reflect.has(tType, 'default') ? {default: tType.default} : undefined; const resolvedType = Type.Union([tType, Type.Null()], options); - if (optional) { + if (optional === undefined || optional) { return Type.Optional(resolvedType); } return resolvedType; -}; +} diff --git a/apps/api/src/plugins/auth0.ts b/apps/api/src/plugins/auth0.ts index 1f86a5283..67b9449fc 100644 --- a/apps/api/src/plugins/auth0.ts +++ b/apps/api/src/plugins/auth0.ts @@ -3,8 +3,7 @@ import '@fastify/jwt'; import { FastifyInstance, FastifyPluginAsync, - FastifyReply, - FastifyRequest, + preHandlerHookHandler, } from 'fastify'; import fastifyAuth0Verify, {Authenticate} from 'fastify-auth0-verify'; import fp from 'fastify-plugin'; @@ -54,67 +53,57 @@ export default fp<{authProvider?: FastifyPluginAsync}>( }); } - async function authorize( - req: FastifyRequest, - reply: FastifyReply, - options: AuthorizeOptions = { - mustBeAuthenticated: true, - }, - ) { - try { - await fastify.authenticate(req, reply); - } catch (e) { - if (options.mustBeAuthenticated) { - throw fastify.httpErrors.unauthorized(); + const authorize: (options: AuthorizeOptions) => preHandlerHookHandler = + (options = {mustBeAuthenticated: true}) => + async (req, reply) => { + try { + await fastify.authenticate(req, reply); + } catch (e) { + if (options.mustBeAuthenticated) { + throw fastify.httpErrors.unauthorized(); + } } - } - const emailClaim = `${fastify.config.AUTH0_CLIENT_CLAIMS}/email`; + const emailClaim = `${fastify.config.AUTH0_CLIENT_CLAIMS}/email`; - if (!req.user) { - req.appUserOptional = null; - return; - } - - const email = req.user[emailClaim] as string; + if (!req.user) { + req.appUserOptional = null; + return; + } - if (!email) { - throw fastify.httpErrors.badRequest('No valid user data'); - } + const email = req.user[emailClaim] as string; - const user = await fastify.prisma.user.findFirst({ - where: { - email, - }, - }); + if (!email) { + throw fastify.httpErrors.badRequest('No valid user data'); + } - if (!user) { - req.appUser = await fastify.prisma.user.create({ - data: { + const user = await fastify.prisma.user.findFirst({ + where: { email, }, }); - } else { - req.appUser = user; - } - req.appUserOptional = req.appUser; - } + if (!user) { + req.appUser = await fastify.prisma.user.create({ + data: { + email, + }, + }); + } else { + req.appUser = user; + } + + req.appUserOptional = req.appUser; + }; - fastify.decorateRequest('appUser', null); - fastify.decorate('authorize', authorize); + fastify.decorate('verifyAuth0', authorize); }, ); declare module 'fastify' { interface FastifyInstance { - authorize: ( - req: FastifyRequest, - reply: FastifyReply, - options?: AuthorizeOptions, - ) => void; + verifyAuth0: (options?: AuthorizeOptions) => preHandlerHookHandler; } - interface FastifyRequest { appUser: User; appUserOptional: User | null; diff --git a/apps/api/src/plugins/auth0Management.ts b/apps/api/src/plugins/auth0Management.ts new file mode 100644 index 000000000..f9aa23f45 --- /dev/null +++ b/apps/api/src/plugins/auth0Management.ts @@ -0,0 +1,20 @@ +import {ManagementClient} from 'auth0'; +import fp from 'fastify-plugin'; + +export default fp(async fastify => { + fastify.decorate( + 'auth0Management', + new ManagementClient({ + domain: fastify.config.DOMAIN_AUTH0!, + audience: fastify.config.AUDIENCE_AUTH0!, + clientId: fastify.config.CLIENT_ID_AUTH0!, + clientSecret: fastify.config.CLIENT_SECRET_AUTH0!, + }), + ); +}); + +declare module 'fastify' { + interface FastifyInstance { + auth0Management: ManagementClient; + } +} diff --git a/apps/api/src/plugins/multi-auth.ts b/apps/api/src/plugins/multi-auth.ts new file mode 100644 index 000000000..dd95fd834 --- /dev/null +++ b/apps/api/src/plugins/multi-auth.ts @@ -0,0 +1,30 @@ +import fastifyAuth from '@fastify/auth'; +import {preHandlerHookHandler} from 'fastify'; +import fp from 'fastify-plugin'; + +interface AuthorizeOptions { + mustBeAuthenticated: boolean; +} +export default fp(async fastify => { + fastify.register(fastifyAuth); + + const preHookHandler: (options: AuthorizeOptions) => preHandlerHookHandler = ( + options = {mustBeAuthenticated: true}, + ) => + function (request, reply, done) { + return fastify + .auth([ + fastify.verifyAuth0(options), + fastify.verifyHankoPasskey(options), + ]) + .apply(this, [request, reply, done]); + }; + + fastify.decorate('authorize', preHookHandler); +}); + +declare module 'fastify' { + interface FastifyInstance { + authorize: (options?: AuthorizeOptions) => preHandlerHookHandler; + } +} diff --git a/apps/api/src/plugins/passkeys.ts b/apps/api/src/plugins/passkeys.ts new file mode 100644 index 000000000..7d9715529 --- /dev/null +++ b/apps/api/src/plugins/passkeys.ts @@ -0,0 +1,56 @@ +import {tenant} from '@teamhanko/passkeys-sdk'; +import {preHandlerHookHandler} from 'fastify'; +import fp from 'fastify-plugin'; + +interface AuthorizeOptions { + mustBeAuthenticated: boolean; +} + +export const passkeysPlugin = fp(async fastify => { + const passkeysApi = tenant({ + tenantId: fastify.config.HANKO_PASSKEYS_TENANT_ID, + apiKey: fastify.config.HANKO_PASSKEYS_API_KEY, + baseUrl: fastify.config.HANKO_PASSKEYS_LOGIN_BASE_URL, + }); + + fastify.decorate('passkeysApi', passkeysApi); + + const verify: (options: AuthorizeOptions) => preHandlerHookHandler = + (options = {mustBeAuthenticated: true}) => + async (req, reply, done) => { + const token = req.headers.authorization + ?.split('Bearer ')[1] + .split('.')[1] as string; + const claims = JSON.parse(atob(token)); + const userId = claims.sub; + + const user = await fastify.prisma.user.findFirst({ + where: { + id: userId, + }, + }); + + if (user) { + console.log('augment request with user', user); + req.appUser = user; + req.appUserOptional = user; + done(); + } else if (options.mustBeAuthenticated) { + throw fastify.httpErrors.unauthorized(); + } + }; + + fastify.decorate('verifyHankoPasskey', verify); +}); + +export default passkeysPlugin; + +declare module 'fastify' { + interface FastifyInstance { + passkeysApi: ReturnType; + + verifyHankoPasskey: ( + options?: AuthorizeOptions, + ) => (req: FastifyRequest, reply: FastifyReply) => void; + } +} diff --git a/apps/api/src/plugins/user.ts b/apps/api/src/plugins/user.ts new file mode 100644 index 000000000..c0ddf778d --- /dev/null +++ b/apps/api/src/plugins/user.ts @@ -0,0 +1,6 @@ +import fp from 'fastify-plugin'; + +export default fp(async fastify => { + fastify.decorateRequest('appUser', null); + fastify.decorateRequest('appUserOptional', null); +}); diff --git a/apps/api/src/routes/v1/passkey/deleteCredential.ts b/apps/api/src/routes/v1/passkey/deleteCredential.ts new file mode 100644 index 000000000..19ad0b2f7 --- /dev/null +++ b/apps/api/src/routes/v1/passkey/deleteCredential.ts @@ -0,0 +1,25 @@ +import {FastifyPluginAsyncTypebox} from '@fastify/type-provider-typebox'; +import {Type} from '@sinclair/typebox'; + +const route: FastifyPluginAsyncTypebox = async fastify => { + fastify.delete( + '/credentials', + { + schema: { + params: Type.Object({ + credentialId: Type.String(), + }), + }, + preValidation: function (request, reply, done) { + return fastify + .auth([fastify.authenticate, fastify.verifyHankoPasskey]) + .apply(this, [request, reply, done]); + }, + }, + async request => { + return fastify.passkeysApi.credential(request.params.credentialId); + }, + ); +}; + +export default route; diff --git a/apps/api/src/routes/v1/passkey/finalizeLogin.ts b/apps/api/src/routes/v1/passkey/finalizeLogin.ts new file mode 100644 index 000000000..0fee7d4c0 --- /dev/null +++ b/apps/api/src/routes/v1/passkey/finalizeLogin.ts @@ -0,0 +1,70 @@ +import {FastifyPluginAsyncTypebox} from '@fastify/type-provider-typebox'; +import {Type} from '@sinclair/typebox'; +import {FastifySchema} from 'fastify'; +import {Nullable} from '../../../common/typebox/nullable.js'; +import {GetApiTypes} from '../../../common/types/extract-api-types.js'; + +const AuthenticatorResponseSchema = Type.Object({ + clientDataJSON: Type.String(), +}); +const AuthenticatorAssertionResponseSchema = Type.Intersect([ + AuthenticatorResponseSchema, + Type.Object({ + authenticatorData: Type.String(), + signature: Type.String(), + userHandle: Nullable(Type.String()), + }), +]); +const CredentialSchema = Type.Object({ + id: Type.String(), + type: Type.String(), +}); +const PublicKeyCredentialSchema = Type.Intersect([ + CredentialSchema, + Type.Object({ + rawId: Type.String(), + clientExtensionResults: Type.Object({}, {additionalProperties: true}), + authenticatorAttachment: Type.Optional(Type.String()), + }), +]); + +const schema = { + tags: ['Passkey'], + summary: 'Finalize the login operation', + body: Type.Intersect([ + PublicKeyCredentialSchema, + Type.Object({ + response: AuthenticatorAssertionResponseSchema, + }), + ]), + response: { + 200: Type.Object({ + token: Nullable(Type.String()), + }), + }, +} satisfies FastifySchema; + +export type PasskeyFinalizeLoginApi = GetApiTypes; + +const route: FastifyPluginAsyncTypebox = async fastify => { + fastify.post('/finalize-login', {schema}, async request => { + fastify.log.info( + `Finalize passkey login for user with id ${request.body.id}`, + ); + return fastify.passkeysApi.login.finalize({ + id: request.body.id, + type: request.body.type, + clientExtensionResults: request.body.clientExtensionResults, + authenticatorAttachment: request.body.authenticatorAttachment, + rawId: request.body.rawId, + response: { + clientDataJSON: request.body.response.clientDataJSON, + signature: request.body.response.signature, + userHandle: request.body.response.userHandle, + authenticatorData: request.body.response.authenticatorData, + }, + }); + }); +}; + +export default route; diff --git a/apps/api/src/routes/v1/passkey/finalizeRegistration.ts b/apps/api/src/routes/v1/passkey/finalizeRegistration.ts new file mode 100644 index 000000000..0ad47ecd7 --- /dev/null +++ b/apps/api/src/routes/v1/passkey/finalizeRegistration.ts @@ -0,0 +1,75 @@ +import {FastifyPluginAsyncTypebox} from '@fastify/type-provider-typebox'; +import {Type} from '@sinclair/typebox'; +import {FastifySchema} from 'fastify'; +import {Nullable} from '../../../common/typebox/nullable.js'; +import {GetApiTypes} from '../../../common/types/extract-api-types.js'; + +const AuthenticatorResponseSchema = Type.Object({ + clientDataJSON: Type.String(), +}); +const AuthenticatorAttestationResponseSchema = Type.Intersect([ + AuthenticatorResponseSchema, + Type.Object({ + attestationObject: Type.String(), + transports: Nullable(Type.Array(Type.String())), + }), +]); +const CredentialSchema = Type.Object({ + id: Type.String(), + type: Type.String(), +}); +const PublicKeyCredentialSchema = Type.Intersect([ + CredentialSchema, + Type.Object({ + rawId: Type.String(), + clientExtensionResults: Type.Object({}, {additionalProperties: true}), + authenticatorAttachment: Type.Optional(Type.String()), + }), +]); + +const schema = { + tags: ['Passkey'], + summary: 'Finish credential registration process', + body: Type.Intersect([ + PublicKeyCredentialSchema, + Type.Object({ + response: AuthenticatorAttestationResponseSchema, + transports: Nullable(Type.Array(Type.String())), + }), + ]), + response: { + 200: Type.Object({ + token: Type.Optional(Type.String()), + }), + }, +} satisfies FastifySchema; + +export type PasskeyFinalizeRegistrationApi = GetApiTypes; + +const route: FastifyPluginAsyncTypebox = async fastify => { + fastify.post( + '/finalize-registration', + { + schema, + preValidation: (req, reply) => fastify.authorize(req, reply), + }, + async request => { + // const {appUser} = request; + return fastify.passkeysApi.registration.finalize({ + rawId: request.body.rawId, + type: request.body.type, + transports: request.body.transports, + authenticatorAttachment: request.body.authenticatorAttachment, + id: request.body.id, + clientExtensionResults: request.body.clientExtensionResults, + response: { + transports: request.body.response.transports, + clientDataJSON: request.body.response.clientDataJSON, + attestationObject: request.body.response.attestationObject, + }, + }); + }, + ); +}; + +export default route; diff --git a/apps/api/src/routes/v1/passkey/listCredentials.ts b/apps/api/src/routes/v1/passkey/listCredentials.ts new file mode 100644 index 000000000..5caf90b70 --- /dev/null +++ b/apps/api/src/routes/v1/passkey/listCredentials.ts @@ -0,0 +1,27 @@ +import {FastifyPluginAsyncTypebox} from '@fastify/type-provider-typebox'; + +const route: FastifyPluginAsyncTypebox = async fastify => { + fastify.get( + '/credentials', + { + preValidation: (request, reply) => fastify.authenticate(request, reply), + }, + async () => { + return fetch( + `https://passkeys.hanko.io/${fastify.config.HANKO_PASSKEYS_TENANT_ID}/credentials?user_id=4676fe25-3660-4c0d-b89e-34b177e759f0`, + { + headers: { + apikey: fastify.config.HANKO_PASSKEYS_API_KEY, + }, + }, + ) + .then(s => s.json()) + .then(s => { + console.log(s); + return s; + }); + }, + ); +}; + +export default route; diff --git a/apps/api/src/routes/v1/passkey/startLogin.ts b/apps/api/src/routes/v1/passkey/startLogin.ts new file mode 100644 index 000000000..f3e84174b --- /dev/null +++ b/apps/api/src/routes/v1/passkey/startLogin.ts @@ -0,0 +1,38 @@ +import {FastifyPluginAsyncTypebox} from '@fastify/type-provider-typebox'; +import {Type} from '@sinclair/typebox'; +import {FastifySchema} from 'fastify'; +import {Nullable} from '../../../common/typebox/nullable.js'; +import {GetApiTypes} from '../../../common/types/extract-api-types.js'; + +const schema = { + tags: ['Passkey'], + summary: 'Initialize a login flow for passkeys', + response: { + 200: Type.Object({ + publicKey: Type.Object({ + challenge: Type.String(), + timeout: Nullable(Type.Number()), + rpId: Nullable(Type.String()), + allowCredentials: Nullable(Type.Array(Type.String())), + userVerification: Nullable(Type.String()), + extensions: Nullable(Type.Object({}, {additionalProperties: true})), + }), + }), + }, +} satisfies FastifySchema; + +export type PasskeyStartLoginApi = GetApiTypes; + +const route: FastifyPluginAsyncTypebox = async fastify => { + fastify.post( + '/start-login', + { + schema, + }, + async () => { + return fastify.passkeysApi.login.initialize(); + }, + ); +}; + +export default route; diff --git a/apps/api/src/routes/v1/passkey/startRegistration.ts b/apps/api/src/routes/v1/passkey/startRegistration.ts new file mode 100644 index 000000000..d0fc94455 --- /dev/null +++ b/apps/api/src/routes/v1/passkey/startRegistration.ts @@ -0,0 +1,80 @@ +import {FastifyPluginAsyncTypebox} from '@fastify/type-provider-typebox'; +import {Type} from '@sinclair/typebox'; +import {FastifySchema} from 'fastify'; +import {Nullable} from '../../../common/typebox/nullable.js'; +import {GetApiTypes} from '../../../common/types/extract-api-types.js'; + +const schema = { + tags: ['Passkey'], + summary: 'Initialize a registration for webauthn credentials', + response: { + 200: Type.Object({ + publicKey: Type.Object({ + rp: Type.Object({ + id: Type.String(), + name: Type.String(), + icon: Nullable(Type.String()), + }), + user: Type.Object({ + id: Type.String(), + displayName: Nullable(Type.String()), + name: Type.String(), + icon: Nullable(Type.String()), + }), + challenge: Type.String(), + pubKeyCredParams: Nullable( + Type.Array( + Type.Object({ + type: Type.String(), + alg: Type.Number(), + }), + ), + ), + timeout: Nullable(Type.Number()), + excludeCredentials: Nullable( + Type.Array( + Type.Object({ + type: Type.String(), + id: Type.String(), + transports: Nullable(Type.Array(Type.String())), + }), + ), + ), + authenticatorSelection: Nullable( + Type.Object({ + authenticatorAttachment: Nullable(Type.String()), + requireResidentKey: Nullable(Type.Boolean()), + residentKey: Nullable(Type.String()), + userVerification: Nullable(Type.String()), + }), + ), + attestation: Nullable(Type.String()), + extensions: Type.Optional(Type.Any()), + }), + }), + }, +} satisfies FastifySchema; + +export type PasskeyStartRegistrationApi = GetApiTypes; + +const route: FastifyPluginAsyncTypebox = async fastify => { + fastify.post( + '/registration', + { + preValidation: (req, reply) => fastify.authorize(req, reply), + schema, + }, + async request => { + const {appUser} = request; + fastify.log.info( + `Init passkey registration for user with id ${appUser.id}`, + ); + return fastify.passkeysApi.registration.initialize({ + userId: appUser.id, + username: appUser.email, + }); + }, + ); +}; + +export default route; diff --git a/apps/api/src/routes/v1/preset/create.ts b/apps/api/src/routes/v1/preset/create.ts index 3e5a911b0..07d03b5dc 100644 --- a/apps/api/src/routes/v1/preset/create.ts +++ b/apps/api/src/routes/v1/preset/create.ts @@ -18,17 +18,14 @@ export type CreatePresetApi = GetApiTypes; // eslint-disable-next-line const createRoute: FastifyPluginAsyncTypebox = async fastify => { - fastify.post( - '/', - { - preValidation: (req, reply) => fastify.authorize(req, reply), - schema, - }, - request => { - const {appUser, body} = request; - return fastify.presetService.createPreset(appUser.id, body); - }, + fastify.addHook( + 'preValidation', + fastify.authorize({mustBeAuthenticated: true}), ); + fastify.post('/', {schema}, request => { + const {appUser, body} = request; + return fastify.presetService.createPreset(appUser.id, body); + }); }; export default createRoute; diff --git a/apps/api/src/routes/v1/preset/delete.ts b/apps/api/src/routes/v1/preset/delete.ts index ba63ae5bb..1c93d992c 100644 --- a/apps/api/src/routes/v1/preset/delete.ts +++ b/apps/api/src/routes/v1/preset/delete.ts @@ -1,5 +1,5 @@ +import {FastifyPluginAsyncTypebox} from '@fastify/type-provider-typebox'; import {Type} from '@sinclair/typebox'; -import {FastifyPluginAsync} from 'fastify'; import {GetApiTypes} from '../../../common/types/extract-api-types.js'; const schema = { @@ -15,23 +15,18 @@ const schema = { export type DeletePresetApi = GetApiTypes; -const deleteRoute: FastifyPluginAsync = async fastify => { - fastify.delete<{ - Params: {id: string}; - }>( - '/:id', - { - preValidation: (req, reply) => fastify.authorize(req, reply), - schema, - }, - async request => { - const { - appUser, - params: {id}, - } = request; - return fastify.presetService.deletePreset(appUser.id, id); - }, +const deleteRoute: FastifyPluginAsyncTypebox = async fastify => { + fastify.addHook( + 'preValidation', + fastify.authorize({mustBeAuthenticated: true}), ); + fastify.delete('/:id', {schema}, async request => { + const { + appUser, + params: {id}, + } = request; + await fastify.presetService.deletePreset(appUser.id, id); + }); }; export default deleteRoute; diff --git a/apps/api/src/routes/v1/preset/getAll.ts b/apps/api/src/routes/v1/preset/getAll.ts index 69da3060e..869cd0010 100644 --- a/apps/api/src/routes/v1/preset/getAll.ts +++ b/apps/api/src/routes/v1/preset/getAll.ts @@ -14,17 +14,14 @@ const schema = { export type GetAllPresetApi = GetApiTypes; const getByIdRoute: FastifyPluginAsyncTypebox = async fastify => { - fastify.get( - '/', - { - preValidation: (req, reply) => fastify.authorize(req, reply), - schema, - }, - async request => { - const {appUser} = request; - return fastify.presetService.findAllPresets(appUser.id); - }, + fastify.addHook( + 'preValidation', + fastify.authorize({mustBeAuthenticated: true}), ); + fastify.get('/', {schema}, async request => { + const {appUser} = request; + return fastify.presetService.findAllPresets(appUser.id); + }); }; export default getByIdRoute; diff --git a/apps/api/src/routes/v1/preset/getById.ts b/apps/api/src/routes/v1/preset/getById.ts index b70cd16bc..13fce9d4a 100644 --- a/apps/api/src/routes/v1/preset/getById.ts +++ b/apps/api/src/routes/v1/preset/getById.ts @@ -17,10 +17,13 @@ const schema = { export type GetPresetByIdApi = GetApiTypes; const getByIdRoute: FastifyPluginAsyncTypebox = async fastify => { + fastify.addHook( + 'preValidation', + fastify.authorize({mustBeAuthenticated: true}), + ); fastify.get( '/:id', { - preValidation: (req, reply) => fastify.authorize(req, reply), schema, }, async request => { diff --git a/apps/api/src/routes/v1/preset/update.ts b/apps/api/src/routes/v1/preset/update.ts index df32855a2..dc6b2fc5c 100644 --- a/apps/api/src/routes/v1/preset/update.ts +++ b/apps/api/src/routes/v1/preset/update.ts @@ -20,20 +20,17 @@ export type UpdatePresetApi = GetApiTypes; // eslint-disable-next-line @typescript-eslint/no-unused-vars const updateRoute: FastifyPluginAsyncTypebox = async fastify => { - fastify.put( - '/:id', - { - preValidation: (req, reply) => fastify.authorize(req, reply), - schema, - }, - request => { - const { - appUser, - params: {id}, - body, - } = request; - return fastify.presetService.updatePreset(appUser.id, id, body); - }, + fastify.addHook( + 'preValidation', + fastify.authorize({mustBeAuthenticated: true}), ); + fastify.put('/:id', {schema}, request => { + const { + appUser, + params: {id}, + body, + } = request; + return fastify.presetService.updatePreset(appUser.id, id, body); + }); }; export default updateRoute; diff --git a/apps/api/src/routes/v1/project/clone.ts b/apps/api/src/routes/v1/project/clone.ts index 28e23dc7c..4fbbe1f1b 100644 --- a/apps/api/src/routes/v1/project/clone.ts +++ b/apps/api/src/routes/v1/project/clone.ts @@ -20,21 +20,18 @@ const schema = { export type CloneProjectApi = GetApiTypes; const cloneRoute: FastifyPluginAsyncTypebox = async fastify => { - fastify.post( - '/:id/clone', - { - preValidation: (req, reply) => fastify.authorize(req, reply), - schema, - }, - request => { - const {appUser, params, body} = request; - return fastify.projectService.clone( - appUser, - params.id, - body.newName ?? null, - ); - }, + fastify.addHook( + 'preValidation', + fastify.authorize({mustBeAuthenticated: true}), ); + fastify.post('/:id/clone', {schema}, request => { + const {appUser, params, body} = request; + return fastify.projectService.clone( + appUser, + params.id, + body.newName ?? null, + ); + }); }; export default cloneRoute; diff --git a/apps/api/src/routes/v1/project/create.ts b/apps/api/src/routes/v1/project/create.ts index 22487ee83..f9cb01977 100644 --- a/apps/api/src/routes/v1/project/create.ts +++ b/apps/api/src/routes/v1/project/create.ts @@ -17,17 +17,14 @@ const schema = { export type CreateProjectApi = GetApiTypes; const createRoute: FastifyPluginAsyncTypebox = async fastify => { - fastify.post( - '/', - { - preValidation: (req, reply) => fastify.authorize(req, reply), - schema, - }, - request => { - const {appUser, body} = request; - return fastify.projectService.createNewProject(appUser.id, body); - }, + fastify.addHook( + 'preValidation', + fastify.authorize({mustBeAuthenticated: true}), ); + fastify.post('/', {schema}, request => { + const {appUser, body} = request; + return fastify.projectService.createNewProject(appUser.id, body); + }); }; export default createRoute; diff --git a/apps/api/src/routes/v1/project/delete.ts b/apps/api/src/routes/v1/project/delete.ts index ff09ceeb8..2443075db 100644 --- a/apps/api/src/routes/v1/project/delete.ts +++ b/apps/api/src/routes/v1/project/delete.ts @@ -1,5 +1,5 @@ +import {FastifyPluginAsyncTypebox} from '@fastify/type-provider-typebox'; import {Type} from '@sinclair/typebox'; -import {FastifyPluginAsync} from 'fastify'; import {GetApiTypes} from '../../../common/types/extract-api-types.js'; import {ProjectDeleteResponseSchema} from '../../../modules/project/schema/index.js'; @@ -16,23 +16,18 @@ const schema = { export type DeleteProjectApi = GetApiTypes; -const deleteRoute: FastifyPluginAsync = async fastify => { - fastify.delete<{ - Params: {id: string}; - }>( - '/:id', - { - preValidation: (req, reply) => fastify.authorize(req, reply), - schema, - }, - async request => { - const { - appUser, - params: {id}, - } = request; - return fastify.projectRepository.deleteProject(id, appUser.id); - }, +const deleteRoute: FastifyPluginAsyncTypebox = async fastify => { + fastify.addHook( + 'preValidation', + fastify.authorize({mustBeAuthenticated: true}), ); + fastify.delete('/:id', {schema}, async request => { + const { + appUser, + params: {id}, + } = request; + return fastify.projectRepository.deleteProject(id, appUser.id); + }); }; export default deleteRoute; diff --git a/apps/api/src/routes/v1/project/getAllByUserId.ts b/apps/api/src/routes/v1/project/getAllByUserId.ts index 9a9fb6128..0e40f2ff1 100644 --- a/apps/api/src/routes/v1/project/getAllByUserId.ts +++ b/apps/api/src/routes/v1/project/getAllByUserId.ts @@ -5,7 +5,7 @@ const getAllByUserIdRoute: FastifyPluginAsync = async fastify => { fastify.get( '/', { - preValidation: (req, reply) => fastify.authorize(req, reply), + preValidation: fastify.authorize({mustBeAuthenticated: true}), schema: { tags: ['Project'], summary: 'Get all CodeImage projects by the current user', diff --git a/apps/api/src/routes/v1/project/getById.ts b/apps/api/src/routes/v1/project/getById.ts index 13c2026f0..87056e51d 100644 --- a/apps/api/src/routes/v1/project/getById.ts +++ b/apps/api/src/routes/v1/project/getById.ts @@ -17,23 +17,17 @@ const schema = { export type GetProjectByIdApi = GetApiTypes; const getByIdRoute: FastifyPluginAsyncTypebox = async fastify => { - fastify.get( - '/:id', - { - preValidation: (req, reply) => - fastify.authorize(req, reply, { - mustBeAuthenticated: false, - }), - schema, - }, - async request => { - const { - appUser, - params: {id}, - } = request; - return fastify.projectService.findById(appUser, id); - }, + fastify.addHook( + 'preValidation', + fastify.authorize({mustBeAuthenticated: false}), ); + fastify.get('/:id', {schema}, async request => { + const { + appUser, + params: {id}, + } = request; + return fastify.projectService.findById(appUser, id); + }); }; export default getByIdRoute; diff --git a/apps/api/src/routes/v1/project/update.ts b/apps/api/src/routes/v1/project/update.ts index 628481d53..b9f90bfac 100644 --- a/apps/api/src/routes/v1/project/update.ts +++ b/apps/api/src/routes/v1/project/update.ts @@ -21,21 +21,18 @@ const schema = { export type UpdateProjectApi = GetApiTypes; const updateRoute: FastifyPluginAsyncTypebox = async fastify => { - fastify.put( - '/:id', - { - preValidation: (req, reply) => fastify.authorize(req, reply), - schema, - }, - request => { - const { - appUser, - body, - params: {id}, - } = request; - return fastify.projectService.update(appUser.id, id, body); - }, + fastify.addHook( + 'preValidation', + fastify.authorize({mustBeAuthenticated: true}), ); + fastify.put('/:id', {schema}, request => { + const { + appUser, + body, + params: {id}, + } = request; + return fastify.projectService.update(appUser.id, id, body); + }); }; export default updateRoute; diff --git a/apps/api/src/routes/v1/project/updateName.ts b/apps/api/src/routes/v1/project/updateName.ts index f00801364..e789e8f07 100644 --- a/apps/api/src/routes/v1/project/updateName.ts +++ b/apps/api/src/routes/v1/project/updateName.ts @@ -1,5 +1,5 @@ -import {Static, Type} from '@sinclair/typebox'; -import {FastifyPluginAsync} from 'fastify'; +import {FastifyPluginAsyncTypebox} from '@fastify/type-provider-typebox'; +import {Type} from '@sinclair/typebox'; import {GetApiTypes} from '../../../common/types/extract-api-types.js'; import {BaseProjectResponseSchema} from '../../../modules/project/schema/project.schema.js'; @@ -19,25 +19,19 @@ const schema = { export type UpdateProjectNameApi = GetApiTypes; -const updateProjectName: FastifyPluginAsync = async fastify => { - fastify.put<{ - Params: Static<(typeof schema)['params']>; - Body: Static<(typeof schema)['body']>; - }>( - '/:id/name', - { - preValidation: (req, reply) => fastify.authorize(req, reply), - schema, - }, - async request => { - const { - body, - appUser, - params: {id}, - } = request; - return fastify.projectService.updateName(appUser.id, id, body.name); - }, +const updateProjectName: FastifyPluginAsyncTypebox = async fastify => { + fastify.addHook( + 'preValidation', + fastify.authorize({mustBeAuthenticated: true}), ); + fastify.put('/:id/name', {schema}, async request => { + const { + body, + appUser, + params: {id}, + } = request; + return fastify.projectService.updateName(appUser.id, id, body.name); + }); }; export default updateProjectName; diff --git a/apps/api/src/routes/v1/user/info.ts b/apps/api/src/routes/v1/user/info.ts new file mode 100644 index 000000000..3a85de922 --- /dev/null +++ b/apps/api/src/routes/v1/user/info.ts @@ -0,0 +1,17 @@ +import {FastifyPluginAsyncTypebox} from '@fastify/type-provider-typebox'; + +const route: FastifyPluginAsyncTypebox = async fastify => { + fastify.addHook( + 'preValidation', + fastify.authorize({mustBeAuthenticated: true}), + ); + fastify.get('/info', {}, async request => { + const response = await fastify.auth0Management.usersByEmail.getByEmail({ + email: request.appUser.email, + fields: 'user_id,email,created_at,email_verified,picture', + }); + return Object.assign(response.data[0], {id: request.appUser.id}); + }); +}; + +export default route; diff --git a/apps/api/src/schemas/index.ts b/apps/api/src/schemas/index.ts index 2fabd041b..b1d08ad49 100644 --- a/apps/api/src/schemas/index.ts +++ b/apps/api/src/schemas/index.ts @@ -10,3 +10,8 @@ export type {GetAllPresetApi} from '../routes/v1/preset/getAll.js'; export type {CreatePresetApi} from '../routes/v1/preset/create.js'; export type {UpdatePresetApi} from '../routes/v1/preset/update.js'; export type {DeletePresetApi} from '../routes/v1/preset/delete.js'; + +export type {PasskeyStartRegistrationApi} from '../routes/v1/passkey/startRegistration.js'; +export type {PasskeyFinalizeRegistrationApi} from '../routes/v1/passkey/finalizeRegistration.js'; +export type {PasskeyStartLoginApi} from '../routes/v1/passkey/startLogin.js'; +export type {PasskeyFinalizeLoginApi} from '../routes/v1/passkey/finalizeLogin.js'; From 494b01828ad35e85e4cc0b934eb9193ba2e9ad0c Mon Sep 17 00:00:00 2001 From: riccardoperra Date: Sun, 17 Dec 2023 17:36:48 +0100 Subject: [PATCH 02/16] chore: update pnpm-lock.yaml --- pnpm-lock.yaml | 163 +++++++++++++++++++++++++++++++------------------ 1 file changed, 105 insertions(+), 58 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 672fc358e..9db1ce6b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -103,13 +103,16 @@ importers: version: 5.3.2 vite: specifier: ^3.2.5 - version: 3.2.5(@types/node@18.16.17) + version: 3.2.5(@types/node@18.16.17)(sass@1.61.0) apps/api: dependencies: '@codeimage/prisma-models': specifier: workspace:* version: link:../../packages/prisma-models + '@fastify/auth': + specifier: 4.4.0 + version: 4.4.0 '@fastify/autoload': specifier: ^5.7.1 version: 5.7.1 @@ -140,6 +143,12 @@ importers: '@sinclair/typebox': specifier: ^0.28.15 version: 0.28.15 + '@teamhanko/passkeys-sdk': + specifier: 0.1.8 + version: 0.1.8 + auth0: + specifier: 4.2.0 + version: 4.2.0 close-with-grace: specifier: ^1.2.0 version: 1.2.0 @@ -161,6 +170,9 @@ importers: fastify-healthcheck: specifier: ^4.4.0 version: 4.4.0 + fastify-jwt-jwks: + specifier: 1.1.4 + version: 1.1.4 fastify-plugin: specifier: ^4.5.0 version: 4.5.0 @@ -303,6 +315,9 @@ importers: '@formatjs/intl-relativetimeformat': specifier: 11.1.4 version: 11.1.4 + '@github/webauthn-json': + specifier: 2.1.1 + version: 2.1.1 '@kobalte/core': specifier: ^0.11.0 version: 0.11.2(solid-js@1.8.6) @@ -592,7 +607,7 @@ importers: version: 5.3.2 vite: specifier: ^3.1.8 - version: 3.2.5(@types/node@18.16.17) + version: 3.2.5(@types/node@18.16.17)(sass@1.61.0) packages/atomic-state: dependencies: @@ -617,7 +632,7 @@ importers: version: 1.8.6 vite: specifier: ^3.2.5 - version: 3.2.5(@types/node@18.16.17) + version: 3.2.5(@types/node@18.16.17)(sass@1.61.0) vite-plugin-dts: specifier: ^1.7.3 version: 1.7.3(@types/node@18.16.17)(rollup@2.79.1)(vite@3.2.5) @@ -729,7 +744,7 @@ importers: version: 5.3.2 vite: specifier: ^3.2.5 - version: 3.2.5(@types/node@18.16.17) + version: 3.2.5(@types/node@18.16.17)(sass@1.61.0) vite-plugin-dts: specifier: ^1.7.3 version: 1.7.3(@types/node@18.16.17)(vite@3.2.5) @@ -769,7 +784,7 @@ importers: version: 5.3.2 vite: specifier: ^3.2.5 - version: 3.2.5(@types/node@18.16.17) + version: 3.2.5(@types/node@18.16.17)(sass@1.61.0) vite-plugin-dts: specifier: ^1.7.3 version: 1.7.3(@types/node@18.16.17)(vite@3.2.5) @@ -860,7 +875,7 @@ importers: version: 5.3.2 vite: specifier: ^3.2.5 - version: 3.2.5(@types/node@18.16.17) + version: 3.2.5(@types/node@18.16.17)(sass@1.61.0) vite-plugin-dts: specifier: ^1.7.3 version: 1.7.3(@types/node@18.16.17)(rollup@2.79.1)(vite@3.2.5) @@ -885,7 +900,7 @@ importers: version: 5.3.2 vite: specifier: ^3.2.5 - version: 3.2.5(@types/node@18.16.17) + version: 3.2.5(@types/node@18.16.17)(sass@1.61.0) vite-plugin-dts: specifier: ^2.2.0 version: 2.2.0(@types/node@18.16.17)(vite@3.2.5) @@ -1035,7 +1050,7 @@ importers: version: 5.3.2 vite: specifier: ^3.2.5 - version: 3.2.5(@types/node@18.16.17) + version: 3.2.5(@types/node@18.16.17)(sass@1.61.0) vite-plugin-dts: specifier: ^1.7.3 version: 1.7.3(@types/node@18.16.17)(rollup@2.79.1)(vite@3.2.5) @@ -3113,6 +3128,13 @@ packages: fast-uri: 2.2.0 dev: false + /@fastify/auth@4.4.0: + resolution: {integrity: sha512-gGFicD/q1MSEPA+qiJAc+egZzzUH6LKJHDdLWQ5TfcgGxfszNMa+Dbx3ehKtGRpB2fOQF41/+yfFv8C12xoBHQ==} + dependencies: + fastify-plugin: 4.5.0 + reusify: 1.0.4 + dev: false + /@fastify/autoload@5.7.1: resolution: {integrity: sha512-F5c94MYAF0tacVu6X4/1ojO7fzmgrJXsqitDtpqknXgiHZpeFNhYSnNCUHPz6UDRKsfkDohmh0fiPTtOd8clzQ==} dependencies: @@ -3174,6 +3196,16 @@ packages: steed: 1.1.3 dev: false + /@fastify/jwt@7.2.4: + resolution: {integrity: sha512-aWJzVb3iZb9xIPjfut8YOrkNEKrZA9xyF2C2Hv9nTheFp7CQPGIZMNTyf3848BsD27nw0JLk8jVLZ2g2DfJOoQ==} + dependencies: + '@fastify/error': 3.2.1 + '@lukeed/ms': 2.0.1 + fast-jwt: 3.3.2 + fastify-plugin: 4.5.0 + steed: 1.1.3 + dev: false + /@fastify/send@2.1.0: resolution: {integrity: sha512-yNYiY6sDkexoJR0D8IDy3aRP3+L4wdqCpvx5WP+VtEU58sn7USmKynBzDQex5X42Zzvw2gNzzYgP90UfWShLFA==} dependencies: @@ -3313,6 +3345,11 @@ packages: resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} dev: true + /@github/webauthn-json@2.1.1: + resolution: {integrity: sha512-XrftRn4z75SnaJOmZQbt7Mk+IIjqVHw+glDGOxuHwXkZBZh/MBoRS7MHjSZMDaLhT4RjN2VqiEU7EOYleuJWSQ==} + hasBin: true + dev: false + /@hapi/hoek@9.3.0: resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==} @@ -5687,6 +5724,12 @@ packages: '@reach/observe-rect': 1.2.0 dev: false + /@teamhanko/passkeys-sdk@0.1.8: + resolution: {integrity: sha512-EtLtFxb9gg403O45vwtC3E7mHav7vDj7rl5ncwegoqThYcMQsxTQVboCIsHqe0GeogNZoriWixmv0Y3UY8wHNQ==} + dependencies: + openapi-fetch: 0.8.2 + dev: false + /@thisbeyond/solid-dnd@0.7.2(solid-js@1.8.6): resolution: {integrity: sha512-He8WC2o0J82UwjkbxNSZ+Lnty42TvCENaZWmdnsrDN7pf+7FFnxt9AD/8tJ3eN8aoRxymX81ZUX6PwcwrFxN0A==} peerDependencies: @@ -6197,7 +6240,7 @@ packages: outdent: 0.8.0 postcss: 8.4.31 postcss-load-config: 3.1.4(postcss@8.4.31)(ts-node@10.9.1) - vite: 3.2.5(@types/node@18.16.17) + vite: 3.2.5(@types/node@18.16.17)(sass@1.61.0) transitivePeerDependencies: - supports-color - ts-node @@ -6824,6 +6867,14 @@ packages: engines: {node: '>=8.0.0'} dev: false + /auth0@4.2.0: + resolution: {integrity: sha512-gn/MuwPC7tmWzgKD0jdrkc+/N1m93xiPhlKQNHmCMAeodW0YSvIniVfBN64V/WujUOjIPqXCH9YrMulQCqE6tg==} + engines: {node: '>=18'} + dependencies: + jose: 4.15.4 + uuid: 9.0.1 + dev: false + /available-typed-arrays@1.0.5: resolution: {integrity: sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==} engines: {node: '>= 0.4'} @@ -8774,6 +8825,16 @@ packages: mnemonist: 0.39.5 dev: false + /fast-jwt@3.3.2: + resolution: {integrity: sha512-H+JYxaFy2LepiC1AQWM/2hzKlQOWaWUkEnu/yebhYu4+ameb3qG77WiRZ1Ct6YBk6d/ESsNguBfTT5+q0XMtKg==} + engines: {node: '>=16 <22'} + dependencies: + '@lukeed/ms': 2.0.1 + asn1.js: 5.4.1 + ecdsa-sig-formatter: 1.0.11 + mnemonist: 0.39.5 + dev: false + /fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} dev: true @@ -8809,7 +8870,7 @@ packages: dependencies: '@fastify/cookie': 8.3.0 '@fastify/jwt': 7.0.0 - fastify-jwt-jwks: 1.1.3 + fastify-jwt-jwks: 1.1.4 fastify-plugin: 4.5.0 transitivePeerDependencies: - encoding @@ -8847,12 +8908,12 @@ packages: '@fastify/under-pressure': 8.2.0 dev: false - /fastify-jwt-jwks@1.1.3: - resolution: {integrity: sha512-0aHfOAhWS1wD754HKb3y7WUfE5TJgDaXzqqAwBRSFsSxYJ5EaAfrsUhDjQOGj6nSYFSRSdCAaZZLeePSiCfR+w==} + /fastify-jwt-jwks@1.1.4: + resolution: {integrity: sha512-U4X96hz8NLR1UdX851aKFYz6yqbApGrdG6aygOe7b+JtRo1mGLEuQ5q15twy4v1BSXIub1RajjFYLnpOeGfnww==} engines: {node: '>= 14.0.0'} dependencies: '@fastify/cookie': 8.3.0 - '@fastify/jwt': 6.7.1 + '@fastify/jwt': 7.2.4 fastify-plugin: 4.5.0 http-errors: 2.0.0 node-cache: 5.1.2 @@ -10345,6 +10406,10 @@ packages: '@sideway/formula': 3.0.1 '@sideway/pinpoint': 2.0.0 + /jose@4.15.4: + resolution: {integrity: sha512-W+oqK4H+r5sITxfxpSU+MMdr/YSWGvgZMQDIsNoBDGGy4i7GBPTtvFKibQzW06n3U3TqHjhvBJsirShsEJ6eeQ==} + dev: false + /joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -12064,10 +12129,20 @@ packages: is-docker: 2.2.1 is-wsl: 2.2.0 + /openapi-fetch@0.8.2: + resolution: {integrity: sha512-4g+NLK8FmQ51RW6zLcCBOVy/lwYmFJiiT+ckYZxJWxUxH4XFhsNcX2eeqVMfVOi+mDNFja6qDXIZAz2c5J/RVw==} + dependencies: + openapi-typescript-helpers: 0.0.5 + dev: false + /openapi-types@12.1.3: resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} dev: false + /openapi-typescript-helpers@0.0.5: + resolution: {integrity: sha512-MRffg93t0hgGZbYTxg60hkRIK2sRuEOHEtCUgMuLgbCC33TMQ68AmxskzUlauzZYD47+ENeGV/ElI7qnWqrAxA==} + dev: false + /optionator@0.9.1: resolution: {integrity: sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==} engines: {node: '>= 0.8.0'} @@ -13764,7 +13839,7 @@ packages: solid-start: 0.2.26(@solidjs/meta@0.28.4)(@solidjs/router@0.8.2)(solid-js@1.8.6)(solid-start-node@0.2.26)(solid-start-static@0.2.26)(vite@3.2.5) terser: 5.17.1 undici: 5.21.1 - vite: 3.2.5(@types/node@18.16.17) + vite: 3.2.5(@types/node@18.16.17)(sass@1.61.0) transitivePeerDependencies: - supports-color @@ -13783,7 +13858,7 @@ packages: solid-ssr: 1.7.0 solid-start: 0.2.26(@solidjs/meta@0.28.4)(@solidjs/router@0.8.2)(solid-js@1.8.6)(solid-start-node@0.2.26)(solid-start-static@0.2.26)(vite@3.2.5) undici: 5.21.1 - vite: 3.2.5(@types/node@18.16.17) + vite: 3.2.5(@types/node@18.16.17)(sass@1.61.0) /solid-start@0.2.26(@solidjs/meta@0.28.4)(@solidjs/router@0.8.2)(solid-js@1.8.6)(solid-start-node@0.2.26)(solid-start-static@0.2.26)(vite@3.2.5): resolution: {integrity: sha512-kne2HZlnSMzsirdnvNs1CsDqBl0L0uvKKt1t4de1CH7JIngyqoMcER97jTE0Ejr84KknANaKAdvJAzZcL7Ueng==} @@ -13852,7 +13927,7 @@ packages: solid-start-static: 0.2.26(solid-start@0.2.26)(undici@5.21.1)(vite@3.2.5) terser: 5.17.1 undici: 5.21.1 - vite: 3.2.5(@types/node@18.16.17) + vite: 3.2.5(@types/node@18.16.17)(sass@1.61.0) vite-plugin-inspect: 0.7.19(rollup@3.25.1)(vite@3.2.5) vite-plugin-solid: 2.7.0(solid-js@1.8.6)(vite@3.2.5) wait-on: 6.0.1(debug@4.3.4) @@ -15067,6 +15142,11 @@ packages: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true + /uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + hasBin: true + dev: false + /v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} @@ -15134,7 +15214,7 @@ packages: pathe: 0.2.0 source-map: 0.6.1 source-map-support: 0.5.21 - vite: 3.2.5(@types/node@18.16.17) + vite: 3.2.5(@types/node@18.16.17)(sass@1.61.0) transitivePeerDependencies: - '@types/node' - less @@ -15155,7 +15235,7 @@ packages: mlly: 1.4.2 pathe: 1.1.1 picocolors: 1.0.0 - vite: 3.2.5(@types/node@18.16.17) + vite: 3.2.5(@types/node@18.16.17)(sass@1.61.0) transitivePeerDependencies: - '@types/node' - less @@ -15180,7 +15260,7 @@ packages: fs-extra: 10.1.0 kolorist: 1.7.0 ts-morph: 17.0.1 - vite: 3.2.5(@types/node@18.16.17) + vite: 3.2.5(@types/node@18.16.17)(sass@1.61.0) transitivePeerDependencies: - '@types/node' - rollup @@ -15201,7 +15281,7 @@ packages: fs-extra: 10.1.0 kolorist: 1.7.0 ts-morph: 17.0.1 - vite: 3.2.5(@types/node@18.16.17) + vite: 3.2.5(@types/node@18.16.17)(sass@1.61.0) transitivePeerDependencies: - '@types/node' - rollup @@ -15224,7 +15304,7 @@ packages: kolorist: 1.7.0 magic-string: 0.29.0 ts-morph: 17.0.1 - vite: 3.2.5(@types/node@18.16.17) + vite: 3.2.5(@types/node@18.16.17)(sass@1.61.0) transitivePeerDependencies: - '@types/node' - rollup @@ -15244,7 +15324,7 @@ packages: kolorist: 1.7.0 sirv: 2.0.3 ufo: 1.1.2 - vite: 3.2.5(@types/node@18.16.17) + vite: 3.2.5(@types/node@18.16.17)(sass@1.61.0) transitivePeerDependencies: - rollup - supports-color @@ -15337,39 +15417,6 @@ packages: fsevents: 2.3.3 dev: false - /vite@3.2.5(@types/node@18.16.17): - resolution: {integrity: sha512-4mVEpXpSOgrssFZAOmGIr85wPHKvaDAcXqxVxVRZhljkJOMZi1ibLibzjLHzJvcok8BMguLc7g1W6W/GqZbLdQ==} - engines: {node: ^14.18.0 || >=16.0.0} - hasBin: true - peerDependencies: - '@types/node': '>= 14' - less: '*' - sass: '*' - stylus: '*' - sugarss: '*' - terser: ^5.4.0 - peerDependenciesMeta: - '@types/node': - optional: true - less: - optional: true - sass: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - dependencies: - '@types/node': 18.16.17 - esbuild: 0.19.8 - postcss: 8.4.32 - resolve: 1.22.8 - rollup: 2.79.1 - optionalDependencies: - fsevents: 2.3.3 - /vite@3.2.5(@types/node@18.16.17)(sass@1.61.0): resolution: {integrity: sha512-4mVEpXpSOgrssFZAOmGIr85wPHKvaDAcXqxVxVRZhljkJOMZi1ibLibzjLHzJvcok8BMguLc7g1W6W/GqZbLdQ==} engines: {node: ^14.18.0 || >=16.0.0} @@ -15397,7 +15444,7 @@ packages: dependencies: '@types/node': 18.16.17 esbuild: 0.19.8 - postcss: 8.4.31 + postcss: 8.4.32 resolve: 1.22.8 rollup: 2.79.1 sass: 1.61.0 @@ -15486,7 +15533,7 @@ packages: tinybench: 2.5.0 tinypool: 0.3.1 tinyspy: 1.1.1 - vite: 3.2.5(@types/node@18.16.17) + vite: 3.2.5(@types/node@18.16.17)(sass@1.61.0) vite-node: 0.26.2(@types/node@18.16.17) transitivePeerDependencies: - less @@ -15551,7 +15598,7 @@ packages: strip-literal: 1.0.1 tinybench: 2.5.0 tinypool: 0.5.0 - vite: 3.2.5(@types/node@18.16.17) + vite: 3.2.5(@types/node@18.16.17)(sass@1.61.0) vite-node: 0.31.4(@types/node@18.16.17) why-is-node-running: 2.2.2 transitivePeerDependencies: From 20165923d10d4f3d3dbcfd0e5d4021419809a075 Mon Sep 17 00:00:00 2001 From: riccardoperra Date: Sun, 17 Dec 2023 21:35:29 +0100 Subject: [PATCH 03/16] feat: better auth plugin structure --- apps/api/package.json | 37 +- apps/api/src/app.ts | 1 - apps/api/src/common/auth/auth0.ts | 100 ++++++ apps/api/src/common/auth/authorize.ts | 3 + .../auth/multiAuth.ts} | 17 +- .../src/{plugins => common/auth}/passkeys.ts | 18 +- apps/api/src/common/auth/user.ts | 12 + apps/api/src/plugins/auth0.ts | 111 ------ apps/api/src/plugins/authentication.ts | 16 + apps/api/src/plugins/user.ts | 6 - apps/api/src/routes/v1/preset/delete.ts | 3 - apps/api/src/routes/v1/user/info.ts | 2 +- apps/api/src/server.ts | 2 +- apps/api/test/helpers/auth0Mock.ts | 2 +- apps/api/test/plugins/auth0.test.ts | 2 +- pnpm-lock.yaml | 322 ++++++++++-------- 16 files changed, 338 insertions(+), 316 deletions(-) create mode 100644 apps/api/src/common/auth/auth0.ts create mode 100644 apps/api/src/common/auth/authorize.ts rename apps/api/src/{plugins/multi-auth.ts => common/auth/multiAuth.ts} (59%) rename apps/api/src/{plugins => common/auth}/passkeys.ts (69%) create mode 100644 apps/api/src/common/auth/user.ts delete mode 100644 apps/api/src/plugins/auth0.ts create mode 100644 apps/api/src/plugins/authentication.ts delete mode 100644 apps/api/src/plugins/user.ts diff --git a/apps/api/package.json b/apps/api/package.json index 2e9c73d3d..e3f59b578 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -40,29 +40,30 @@ "license": "ISC", "dependencies": { "@codeimage/prisma-models": "workspace:*", - "@fastify/auth": "4.4.0", - "@fastify/autoload": "^5.7.1", - "@fastify/cors": "^8.3.0", - "@fastify/env": "^4.2.0", - "@fastify/jwt": "^6.7.1", - "@fastify/sensible": "^5.2.0", - "@fastify/swagger": "^8.5.1", - "@fastify/swagger-ui": "^1.8.1", - "@fastify/type-provider-typebox": "^3.2.0", + "@fastify/auth": "^4.4.0", + "@fastify/autoload": "^5.8.0", + "@fastify/cors": "^8.4.2", + "@fastify/env": "^4.3.0", + "@fastify/jwt": "^7.2.4", + "@fastify/sensible": "^5.5.0", + "@fastify/swagger": "^8.12.1", + "@fastify/swagger-ui": "^2.0.1", + "@fastify/type-provider-typebox": "^3.5.0", "@prisma/client": "^4.15.0", - "@sinclair/typebox": "^0.28.15", - "@teamhanko/passkeys-sdk": "0.1.8", + "@sinclair/typebox": "^0.31.28", + "@teamhanko/passkeys-sdk": "^0.1.8", "auth0": "4.2.0", "close-with-grace": "^1.2.0", "dotenv": "^16.1.4", "dotenv-cli": "^6.0.0", - "fastify": "^4.18.0", - "fastify-auth0-verify": "^1.2.0", - "fastify-cli": "^5.7.1", + "fastify": "^4.25.1", + "fastify-auth0-verify": "^1.2.1", + "fastify-cli": "^5.9.0", "fastify-healthcheck": "^4.4.0", - "fastify-jwt-jwks": "1.1.4", - "fastify-plugin": "^4.5.0", - "fluent-json-schema": "^4.1.0", + "fastify-jwt-jwks": "^1.1.4", + "fastify-overview": "^3.6.0", + "fastify-plugin": "^4.5.1", + "fluent-json-schema": "^4.2.1", "prisma": "^4.15.0" }, "devDependencies": { @@ -70,7 +71,7 @@ "@types/sinon": "^10.0.15", "@vitest/ui": "^0.31.4", "concurrently": "^7.6.0", - "fastify-tsconfig": "^1.0.1", + "fastify-tsconfig": "^2.0.0", "sinon": "^15.1.2", "tsup": "6.7.0", "tsx": "3.12.7", diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 6ce990b15..64fa48557 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -2,7 +2,6 @@ import AutoLoad, {AutoloadPluginOptions} from '@fastify/autoload'; import fastifyEnv from '@fastify/env'; import {Type} from '@sinclair/typebox'; import {FastifyPluginAsync} from 'fastify'; -import fp from 'fastify-plugin'; import path, {join} from 'node:path'; import {fileURLToPath} from 'node:url'; diff --git a/apps/api/src/common/auth/auth0.ts b/apps/api/src/common/auth/auth0.ts new file mode 100644 index 000000000..b6a8db520 --- /dev/null +++ b/apps/api/src/common/auth/auth0.ts @@ -0,0 +1,100 @@ +import '@fastify/jwt'; +import { + FastifyInstance, + FastifyPluginAsync, + preHandlerHookHandler, +} from 'fastify'; +import fastifyAuth0Verify, {Authenticate} from 'fastify-auth0-verify'; +import fp from 'fastify-plugin'; +import {AuthorizeOptions} from './authorize.js'; + +declare module '@fastify/jwt' { + interface FastifyJWT { + payload: {id: number}; // payload type is used for signing and verifying + user: Record; // user type is return type of `request.user` object + } +} + +export function mockAuthProvider(context: {email: string}) { + return fp(async (fastify: FastifyInstance) => { + const auth0Authenticate: Authenticate = async req => { + const email = context.email; + const clientClaim = fastify.config.AUTH0_CLIENT_CLAIMS; + const emailKey = `${clientClaim}/email`; + req.user = { + [emailKey]: email, + }; + }; + + fastify.decorateRequest('user', null); + fastify.decorate('authenticate', auth0Authenticate); + }); +} + +export const auth0Plugin: FastifyPluginAsync<{ + authProvider?: FastifyPluginAsync; +}> = async (fastify, options) => { + if (fastify.config.MOCK_AUTH) { + await fastify.register( + mockAuthProvider({ + email: fastify.config.MOCK_AUTH_EMAIL as string, + }), + ); + } else if (options.authProvider) { + await fastify.register(options.authProvider); + } else { + await fastify.register(fastifyAuth0Verify.default, { + domain: fastify.config.DOMAIN_AUTH0, + secret: fastify.config.CLIENT_SECRET_AUTH, + audience: fastify.config.AUDIENCE_AUTH0, + }); + } + + const authorize: (options?: AuthorizeOptions) => preHandlerHookHandler = + (options = {mustBeAuthenticated: true}) => + async (req, reply) => { + try { + await fastify.authenticate(req, reply); + } catch (e) { + if (options.mustBeAuthenticated) { + throw fastify.httpErrors.unauthorized(); + } + } + + const emailClaim = `${fastify.config.AUTH0_CLIENT_CLAIMS}/email`; + + if (!req.user && options.mustBeAuthenticated) { + throw fastify.httpErrors.unauthorized(); + } + + const email = req.user[emailClaim] as string; + + if (!email) { + throw fastify.httpErrors.badRequest('No valid user data'); + } + + const user = await fastify.prisma.user.findFirst({ + where: { + email, + }, + }); + + if (!user) { + req.appUser = await fastify.prisma.user.create({ + data: { + email, + }, + }); + } else { + req.appUser = user; + } + }; + + fastify.decorate('verifyAuth0', authorize); +}; + +declare module 'fastify' { + interface FastifyInstance { + verifyAuth0: (options?: AuthorizeOptions) => preHandlerHookHandler; + } +} diff --git a/apps/api/src/common/auth/authorize.ts b/apps/api/src/common/auth/authorize.ts new file mode 100644 index 000000000..a1403accf --- /dev/null +++ b/apps/api/src/common/auth/authorize.ts @@ -0,0 +1,3 @@ +export interface AuthorizeOptions { + mustBeAuthenticated: boolean; +} diff --git a/apps/api/src/plugins/multi-auth.ts b/apps/api/src/common/auth/multiAuth.ts similarity index 59% rename from apps/api/src/plugins/multi-auth.ts rename to apps/api/src/common/auth/multiAuth.ts index dd95fd834..8ec627308 100644 --- a/apps/api/src/plugins/multi-auth.ts +++ b/apps/api/src/common/auth/multiAuth.ts @@ -1,16 +1,13 @@ import fastifyAuth from '@fastify/auth'; -import {preHandlerHookHandler} from 'fastify'; -import fp from 'fastify-plugin'; +import {FastifyPluginAsync, preHandlerHookHandler} from 'fastify'; +import {AuthorizeOptions} from './authorize.js'; -interface AuthorizeOptions { - mustBeAuthenticated: boolean; -} -export default fp(async fastify => { +export const multiAuthProviderPlugin: FastifyPluginAsync = async fastify => { fastify.register(fastifyAuth); - const preHookHandler: (options: AuthorizeOptions) => preHandlerHookHandler = ( - options = {mustBeAuthenticated: true}, - ) => + const preHookHandler: ( + options?: AuthorizeOptions, + ) => preHandlerHookHandler = (options = {mustBeAuthenticated: true}) => function (request, reply, done) { return fastify .auth([ @@ -21,7 +18,7 @@ export default fp(async fastify => { }; fastify.decorate('authorize', preHookHandler); -}); +}; declare module 'fastify' { interface FastifyInstance { diff --git a/apps/api/src/plugins/passkeys.ts b/apps/api/src/common/auth/passkeys.ts similarity index 69% rename from apps/api/src/plugins/passkeys.ts rename to apps/api/src/common/auth/passkeys.ts index 7d9715529..c2c99f4df 100644 --- a/apps/api/src/plugins/passkeys.ts +++ b/apps/api/src/common/auth/passkeys.ts @@ -1,12 +1,11 @@ import {tenant} from '@teamhanko/passkeys-sdk'; -import {preHandlerHookHandler} from 'fastify'; -import fp from 'fastify-plugin'; +import {FastifyPluginAsync, preHandlerHookHandler} from 'fastify'; interface AuthorizeOptions { mustBeAuthenticated: boolean; } -export const passkeysPlugin = fp(async fastify => { +export const passkeysPlugin: FastifyPluginAsync = async fastify => { const passkeysApi = tenant({ tenantId: fastify.config.HANKO_PASSKEYS_TENANT_ID, apiKey: fastify.config.HANKO_PASSKEYS_API_KEY, @@ -15,9 +14,9 @@ export const passkeysPlugin = fp(async fastify => { fastify.decorate('passkeysApi', passkeysApi); - const verify: (options: AuthorizeOptions) => preHandlerHookHandler = + const verify: (options?: AuthorizeOptions) => preHandlerHookHandler = (options = {mustBeAuthenticated: true}) => - async (req, reply, done) => { + async req => { const token = req.headers.authorization ?.split('Bearer ')[1] .split('.')[1] as string; @@ -31,17 +30,14 @@ export const passkeysPlugin = fp(async fastify => { }); if (user) { - console.log('augment request with user', user); req.appUser = user; - req.appUserOptional = user; - done(); } else if (options.mustBeAuthenticated) { throw fastify.httpErrors.unauthorized(); } }; fastify.decorate('verifyHankoPasskey', verify); -}); +}; export default passkeysPlugin; @@ -49,8 +45,6 @@ declare module 'fastify' { interface FastifyInstance { passkeysApi: ReturnType; - verifyHankoPasskey: ( - options?: AuthorizeOptions, - ) => (req: FastifyRequest, reply: FastifyReply) => void; + verifyHankoPasskey: (options?: AuthorizeOptions) => preHandlerHookHandler; } } diff --git a/apps/api/src/common/auth/user.ts b/apps/api/src/common/auth/user.ts new file mode 100644 index 000000000..6ed931d6d --- /dev/null +++ b/apps/api/src/common/auth/user.ts @@ -0,0 +1,12 @@ +import {FastifyPluginAsync} from 'fastify'; +import {User} from '@codeimage/prisma-models'; + +export const appUserPlugin: FastifyPluginAsync = async fastify => { + fastify.decorateRequest('appUser', null); +}; + +declare module 'fastify' { + interface FastifyRequest { + appUser: User; + } +} diff --git a/apps/api/src/plugins/auth0.ts b/apps/api/src/plugins/auth0.ts deleted file mode 100644 index 67b9449fc..000000000 --- a/apps/api/src/plugins/auth0.ts +++ /dev/null @@ -1,111 +0,0 @@ -import {User} from '@codeimage/prisma-models'; -import '@fastify/jwt'; -import { - FastifyInstance, - FastifyPluginAsync, - preHandlerHookHandler, -} from 'fastify'; -import fastifyAuth0Verify, {Authenticate} from 'fastify-auth0-verify'; -import fp from 'fastify-plugin'; - -declare module '@fastify/jwt' { - interface FastifyJWT { - payload: {id: number}; // payload type is used for signing and verifying - user: Record; // user type is return type of `request.user` object - } -} - -interface AuthorizeOptions { - mustBeAuthenticated: boolean; -} - -export function mockAuthProvider(context: {email: string}) { - return fp(async (fastify: FastifyInstance) => { - const auth0Authenticate: Authenticate = async req => { - const email = context.email; - const clientClaim = fastify.config.AUTH0_CLIENT_CLAIMS; - const emailKey = `${clientClaim}/email`; - req.user = { - [emailKey]: email, - }; - }; - - fastify.decorateRequest('user', null); - fastify.decorate('authenticate', auth0Authenticate); - }); -} - -export default fp<{authProvider?: FastifyPluginAsync}>( - async (fastify, options) => { - if (fastify.config.MOCK_AUTH) { - await fastify.register( - mockAuthProvider({ - email: fastify.config.MOCK_AUTH_EMAIL as string, - }), - ); - } else if (options.authProvider) { - await fastify.register(options.authProvider); - } else { - await fastify.register(fastifyAuth0Verify.default, { - domain: fastify.config.DOMAIN_AUTH0, - secret: fastify.config.CLIENT_SECRET_AUTH, - audience: fastify.config.AUDIENCE_AUTH0, - }); - } - - const authorize: (options: AuthorizeOptions) => preHandlerHookHandler = - (options = {mustBeAuthenticated: true}) => - async (req, reply) => { - try { - await fastify.authenticate(req, reply); - } catch (e) { - if (options.mustBeAuthenticated) { - throw fastify.httpErrors.unauthorized(); - } - } - - const emailClaim = `${fastify.config.AUTH0_CLIENT_CLAIMS}/email`; - - if (!req.user) { - req.appUserOptional = null; - return; - } - - const email = req.user[emailClaim] as string; - - if (!email) { - throw fastify.httpErrors.badRequest('No valid user data'); - } - - const user = await fastify.prisma.user.findFirst({ - where: { - email, - }, - }); - - if (!user) { - req.appUser = await fastify.prisma.user.create({ - data: { - email, - }, - }); - } else { - req.appUser = user; - } - - req.appUserOptional = req.appUser; - }; - - fastify.decorate('verifyAuth0', authorize); - }, -); - -declare module 'fastify' { - interface FastifyInstance { - verifyAuth0: (options?: AuthorizeOptions) => preHandlerHookHandler; - } - interface FastifyRequest { - appUser: User; - appUserOptional: User | null; - } -} diff --git a/apps/api/src/plugins/authentication.ts b/apps/api/src/plugins/authentication.ts new file mode 100644 index 000000000..864165b12 --- /dev/null +++ b/apps/api/src/plugins/authentication.ts @@ -0,0 +1,16 @@ +import fp from 'fastify-plugin'; +import {auth0Plugin} from '../common/auth/auth0.js'; +import {multiAuthProviderPlugin} from '../common/auth/multiAuth.js'; +import passkeysPlugin from '../common/auth/passkeys.js'; +import {appUserPlugin} from '../common/auth/user.js'; + +export default fp( + async fastify => { + fastify + .register(fp(appUserPlugin)) + .register(fp(passkeysPlugin)) + .register(fp(auth0Plugin)) + .register(fp(multiAuthProviderPlugin)); + }, + {encapsulate: false}, +); diff --git a/apps/api/src/plugins/user.ts b/apps/api/src/plugins/user.ts deleted file mode 100644 index c0ddf778d..000000000 --- a/apps/api/src/plugins/user.ts +++ /dev/null @@ -1,6 +0,0 @@ -import fp from 'fastify-plugin'; - -export default fp(async fastify => { - fastify.decorateRequest('appUser', null); - fastify.decorateRequest('appUserOptional', null); -}); diff --git a/apps/api/src/routes/v1/preset/delete.ts b/apps/api/src/routes/v1/preset/delete.ts index 1c93d992c..04dfa09aa 100644 --- a/apps/api/src/routes/v1/preset/delete.ts +++ b/apps/api/src/routes/v1/preset/delete.ts @@ -8,9 +8,6 @@ const schema = { id: Type.String(), }), summary: 'Delete an existing CodeImage preset', - response: { - 200: Type.Void(), - }, }; export type DeletePresetApi = GetApiTypes; diff --git a/apps/api/src/routes/v1/user/info.ts b/apps/api/src/routes/v1/user/info.ts index 3a85de922..5e1447055 100644 --- a/apps/api/src/routes/v1/user/info.ts +++ b/apps/api/src/routes/v1/user/info.ts @@ -10,7 +10,7 @@ const route: FastifyPluginAsyncTypebox = async fastify => { email: request.appUser.email, fields: 'user_id,email,created_at,email_verified,picture', }); - return Object.assign(response.data[0], {id: request.appUser.id}); + return {...response.data[0], id: request.appUser.id}; }); }; diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index c22284239..7d4d1edc8 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -26,7 +26,7 @@ const closeListeners = closeWithGrace({delay: 500}, async function ({ await app.close(); } as closeWithGrace.CloseWithGraceAsyncCallback); -app.addHook('onClose', async (instance, done) => { +app.addHook('onClose', (instance, done) => { closeListeners.uninstall(); done(); }); diff --git a/apps/api/test/helpers/auth0Mock.ts b/apps/api/test/helpers/auth0Mock.ts index 51b4a915e..f5e35d6c3 100644 --- a/apps/api/test/helpers/auth0Mock.ts +++ b/apps/api/test/helpers/auth0Mock.ts @@ -1,6 +1,6 @@ import {User} from '@codeimage/prisma-models'; import {TestContext} from 'vitest'; -import {mockAuthProvider} from '../../src/plugins/auth0.js'; +import {mockAuthProvider} from '../../src/common/auth/auth0.js'; export const auth0Mock = (t: TestContext & T) => mockAuthProvider(t.user); diff --git a/apps/api/test/plugins/auth0.test.ts b/apps/api/test/plugins/auth0.test.ts index 8a10b0820..b02f71350 100644 --- a/apps/api/test/plugins/auth0.test.ts +++ b/apps/api/test/plugins/auth0.test.ts @@ -5,7 +5,7 @@ import Fastify from 'fastify'; import fp from 'fastify-plugin'; import * as sinon from 'sinon'; import {assert, beforeEach, test, TestContext} from 'vitest'; -import auth0 from '../../src/plugins/auth0.js'; +import auth0 from '../../src/common/auth/auth0.js'; import prisma from '../../src/plugins/prisma.js'; import sensible from '../../src/plugins/sensible.js'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9db1ce6b8..175d837d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,40 +111,40 @@ importers: specifier: workspace:* version: link:../../packages/prisma-models '@fastify/auth': - specifier: 4.4.0 + specifier: ^4.4.0 version: 4.4.0 '@fastify/autoload': - specifier: ^5.7.1 - version: 5.7.1 + specifier: ^5.8.0 + version: 5.8.0 '@fastify/cors': - specifier: ^8.3.0 - version: 8.3.0 + specifier: ^8.4.2 + version: 8.4.2 '@fastify/env': - specifier: ^4.2.0 - version: 4.2.0 + specifier: ^4.3.0 + version: 4.3.0 '@fastify/jwt': - specifier: ^6.7.1 - version: 6.7.1 + specifier: ^7.2.4 + version: 7.2.4 '@fastify/sensible': - specifier: ^5.2.0 - version: 5.2.0 + specifier: ^5.5.0 + version: 5.5.0 '@fastify/swagger': - specifier: ^8.5.1 - version: 8.5.1 + specifier: ^8.12.1 + version: 8.12.1 '@fastify/swagger-ui': - specifier: ^1.8.1 - version: 1.8.1 + specifier: ^2.0.1 + version: 2.0.1 '@fastify/type-provider-typebox': - specifier: ^3.2.0 - version: 3.2.0(@sinclair/typebox@0.28.15) + specifier: ^3.5.0 + version: 3.5.0(@sinclair/typebox@0.31.28) '@prisma/client': specifier: ^4.15.0 version: 4.15.0(prisma@4.15.0) '@sinclair/typebox': - specifier: ^0.28.15 - version: 0.28.15 + specifier: ^0.31.28 + version: 0.31.28 '@teamhanko/passkeys-sdk': - specifier: 0.1.8 + specifier: ^0.1.8 version: 0.1.8 auth0: specifier: 4.2.0 @@ -159,26 +159,29 @@ importers: specifier: ^6.0.0 version: 6.0.0 fastify: - specifier: ^4.18.0 - version: 4.18.0 + specifier: ^4.25.1 + version: 4.25.1 fastify-auth0-verify: - specifier: ^1.2.0 - version: 1.2.0 + specifier: ^1.2.1 + version: 1.2.1 fastify-cli: - specifier: ^5.7.1 - version: 5.7.1 + specifier: ^5.9.0 + version: 5.9.0 fastify-healthcheck: specifier: ^4.4.0 version: 4.4.0 fastify-jwt-jwks: - specifier: 1.1.4 + specifier: ^1.1.4 version: 1.1.4 + fastify-overview: + specifier: ^3.6.0 + version: 3.6.0 fastify-plugin: - specifier: ^4.5.0 - version: 4.5.0 + specifier: ^4.5.1 + version: 4.5.1 fluent-json-schema: - specifier: ^4.1.0 - version: 4.1.0 + specifier: ^4.2.1 + version: 4.2.1 prisma: specifier: ^4.15.0 version: 4.15.0 @@ -196,8 +199,8 @@ importers: specifier: ^7.6.0 version: 7.6.0 fastify-tsconfig: - specifier: ^1.0.1 - version: 1.0.1 + specifier: ^2.0.0 + version: 2.0.0 sinon: specifier: ^15.1.2 version: 15.1.2 @@ -3131,27 +3134,32 @@ packages: /@fastify/auth@4.4.0: resolution: {integrity: sha512-gGFicD/q1MSEPA+qiJAc+egZzzUH6LKJHDdLWQ5TfcgGxfszNMa+Dbx3ehKtGRpB2fOQF41/+yfFv8C12xoBHQ==} dependencies: - fastify-plugin: 4.5.0 + fastify-plugin: 4.5.1 reusify: 1.0.4 dev: false - /@fastify/autoload@5.7.1: - resolution: {integrity: sha512-F5c94MYAF0tacVu6X4/1ojO7fzmgrJXsqitDtpqknXgiHZpeFNhYSnNCUHPz6UDRKsfkDohmh0fiPTtOd8clzQ==} - dependencies: - pkg-up: 3.1.0 + /@fastify/autoload@5.8.0: + resolution: {integrity: sha512-bF86vl+1Kk91S41WIL9NrKhcugGQg/cQ959aTaombkCjA+9YAbgVCKKu2lRqtMsosDZ0CNRfVnaLYoHQIDUI2A==} dev: false /@fastify/cookie@8.3.0: resolution: {integrity: sha512-P9hY9GO11L20TnZ33XN3i0bt+3x0zaT7S0ohAzWO950E9PB2xnNhLYzPFJIGFi5AVN0yr5+/iZhWxeYvR6KCzg==} dependencies: cookie: 0.5.0 - fastify-plugin: 4.5.0 + fastify-plugin: 4.5.1 dev: false - /@fastify/cors@8.3.0: - resolution: {integrity: sha512-oj9xkka2Tg0MrwuKhsSUumcAkfp2YCnKxmFEusi01pjk1YrdDsuSYTHXEelWNW+ilSy/ApZq0c2SvhKrLX0H1g==} + /@fastify/cookie@9.2.0: + resolution: {integrity: sha512-fkg1yjjQRHPFAxSHeLC8CqYuNzvR6Lwlj/KjrzQcGjNBK+K82nW+UfCjfN71g1GkoVoc1GTOgIWkFJpcMfMkHQ==} dependencies: - fastify-plugin: 4.5.0 + cookie-signature: 1.2.1 + fastify-plugin: 4.5.1 + dev: false + + /@fastify/cors@8.4.2: + resolution: {integrity: sha512-IVynbcPG9eWiJ0P/A1B+KynmiU/yTYbu3ooBUSIeHfca/N1XLb9nIJVCws+YTr2q63MA8Y6QLeXQczEv4npM9g==} + dependencies: + fastify-plugin: 4.5.1 mnemonist: 0.39.5 dev: false @@ -3159,41 +3167,25 @@ packages: resolution: {integrity: sha512-J8TOSBq3SoZbDhM9+R/u77hP93gz/rajSA+K2kGyijPpORPWUXHUpTaleoj+92As0S9uPRP7Oi8IqMf0u+ro6A==} dev: false - /@fastify/env@4.2.0: - resolution: {integrity: sha512-sj1ehQZPD6tty+6bhzZw1uS2K2s6eOB46maJ2fE+AuTYxdHKVVW/AHXqCYGu3nH9kgzdXsheu3/148ZoBeoQOw==} + /@fastify/env@4.3.0: + resolution: {integrity: sha512-WSredffWvaYjiwHGK5wvY33LFi39gAasBMWelglA6Jk+d7uj/ZWp7icaPoM0kSR5g9M8OOALEvk+8SXiNhK1YA==} dependencies: env-schema: 5.2.0 - fastify-plugin: 4.5.0 + fastify-plugin: 4.5.1 dev: false /@fastify/error@3.2.1: resolution: {integrity: sha512-scZVbcpPNWw/yyFmzzO7cf1daTeJp53spN2n7dBTHZd+cV7791fcWJCPP1Tfhdbre+8vDiCyQyqqXfQnYMntYQ==} dev: false - /@fastify/fast-json-stringify-compiler@4.3.0: - resolution: {integrity: sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==} - dependencies: - fast-json-stringify: 5.7.0 + /@fastify/error@3.4.1: + resolution: {integrity: sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==} dev: false - /@fastify/jwt@6.7.1: - resolution: {integrity: sha512-pvRcGeyF2H1U+HXaxlRBd6s1y99vbSZjhpxTWECIGIhMXKRxBTBSUPRF7LJGONlW1/pZstQ0/Dp/ZxBFlDuEnw==} - dependencies: - '@fastify/error': 3.2.1 - '@lukeed/ms': 2.0.1 - fast-jwt: 2.2.3 - fastify-plugin: 4.5.0 - steed: 1.1.3 - dev: false - - /@fastify/jwt@7.0.0: - resolution: {integrity: sha512-y8n7qhBb/U+qWRUJzjZ+SJckv9wmOrA1eC/lM34SnOopt7VlK3hdfox7T3iuurAWVb8HjS9GxmZx+zFZO+Vb5A==} + /@fastify/fast-json-stringify-compiler@4.3.0: + resolution: {integrity: sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==} dependencies: - '@fastify/error': 3.2.1 - '@lukeed/ms': 2.0.1 - fast-jwt: 3.1.1 - fastify-plugin: 4.5.0 - steed: 1.1.3 + fast-json-stringify: 5.9.1 dev: false /@fastify/jwt@7.2.4: @@ -3202,7 +3194,7 @@ packages: '@fastify/error': 3.2.1 '@lukeed/ms': 2.0.1 fast-jwt: 3.3.2 - fastify-plugin: 4.5.0 + fastify-plugin: 4.5.1 steed: 1.1.3 dev: false @@ -3216,14 +3208,14 @@ packages: mime: 3.0.0 dev: false - /@fastify/sensible@5.2.0: - resolution: {integrity: sha512-fy5vqJJAMVQctUT+kYfGdaGYeW9d8JaWEqtdWN6UmzQQ3VX0qMXE7Qc/MmfE+Cj3v0g5kbeR+obd3HZr3IMf+w==} + /@fastify/sensible@5.5.0: + resolution: {integrity: sha512-D0zpl+nocsRXLceSbc4gasQaO3ZNQR4dy9Uu8Ym0mh8VUdrjpZ4g8Ca9O3pGXbBVOnPIGHUJNTV7Yf9dg/OYdg==} dependencies: + '@lukeed/ms': 2.0.1 fast-deep-equal: 3.1.3 - fastify-plugin: 4.5.0 + fastify-plugin: 4.5.1 forwarded: 0.2.0 http-errors: 2.0.0 - ms: 2.1.3 type-is: 1.6.18 vary: 1.1.2 dev: false @@ -3234,26 +3226,26 @@ packages: '@fastify/accept-negotiator': 1.1.0 '@fastify/send': 2.1.0 content-disposition: 0.5.4 - fastify-plugin: 4.5.0 + fastify-plugin: 4.5.1 glob: 8.1.0 p-limit: 3.1.0 readable-stream: 4.4.0 dev: false - /@fastify/swagger-ui@1.8.1: - resolution: {integrity: sha512-XMfLGZMXi5dl0Gy6R6tlistA4d0XlJJweUfQkPNVeeBq2hO03DmvtM5yrp8adF392Xoi+6rlGHFaneL9EQdsoA==} + /@fastify/swagger-ui@2.0.1: + resolution: {integrity: sha512-sQnufSdQ5kJxaTxBisWYQjkunECuRymYRZYEZEEPpmLUzzZoS22tDLVumb3c1TV4MAlD3L1LTLpxLSXcFL+OZw==} dependencies: '@fastify/static': 6.10.2 - fastify-plugin: 4.5.0 + fastify-plugin: 4.5.1 openapi-types: 12.1.3 rfdc: 1.3.0 yaml: 2.3.1 dev: false - /@fastify/swagger@8.5.1: - resolution: {integrity: sha512-tP8nRndpHKE48yhH67nuGxB0Dp1w8/nTpFfKfix4finfTDnMPu1YuevlcupIHWr/+9v6h+TQlY4/7UKFXSGfaA==} + /@fastify/swagger@8.12.1: + resolution: {integrity: sha512-0GATwS+a1QHHhTYtyZfoIpRD5lL1XlDSiV2DqsTVMQxKpL18kx5o6oMz0l0rtFr4883XIGiRuvTv2rxFRIxp4Q==} dependencies: - fastify-plugin: 4.5.0 + fastify-plugin: 4.5.1 json-schema-resolver: 2.0.0 openapi-types: 12.1.3 rfdc: 1.3.0 @@ -3262,19 +3254,19 @@ packages: - supports-color dev: false - /@fastify/type-provider-typebox@3.2.0(@sinclair/typebox@0.28.15): - resolution: {integrity: sha512-Ec+dHVfb9wovQ/jclkDbzTshHTtSsDmTGhls6S/TM3QIcx7682Jq8/W0kHXW3ylBUBEtcel87B+PuwH17TRDAw==} + /@fastify/type-provider-typebox@3.5.0(@sinclair/typebox@0.31.28): + resolution: {integrity: sha512-f48uGzvLflE/y4pvXOS8qjAC+mZmlqev9CPHnB8NDsBSL4EbeydO61IgPuzOkeNlAYeRP9Y56UOKj1XWFibgMw==} peerDependencies: - '@sinclair/typebox': ^0.28.0 + '@sinclair/typebox': '>=0.26 <=0.31' dependencies: - '@sinclair/typebox': 0.28.15 + '@sinclair/typebox': 0.31.28 dev: false /@fastify/under-pressure@8.2.0: resolution: {integrity: sha512-tqhWBhE6blM3jDn9dmxl5yhyoRWGFGwdihLmyek5pRN3KIOEcRQVtAoX5WKSbeEZ51clnDfCNqm96YC4cEUI7g==} dependencies: '@fastify/error': 3.2.1 - fastify-plugin: 4.5.0 + fastify-plugin: 4.5.1 dev: false /@floating-ui/core@1.5.0: @@ -5281,8 +5273,8 @@ packages: /@sideway/pinpoint@2.0.0: resolution: {integrity: sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==} - /@sinclair/typebox@0.28.15: - resolution: {integrity: sha512-IUHNXCbehBRC1yC1PVtzOHuDaqb30NnCquY3T8VFChu8Jy+wwl1l/XJ0VhC/EEUPi9MBQ8KTeWGT/KmbhztU4g==} + /@sinclair/typebox@0.31.28: + resolution: {integrity: sha512-/s55Jujywdw/Jpan+vsy6JZs1z2ZTGxTmbZTPiuSL2wz9mfzA2gN1zzaqmvfi4pq+uOt7Du85fkiwv5ymW84aQ==} dev: false /@sindresorhus/is@4.6.0: @@ -7784,6 +7776,11 @@ packages: /cookie-signature@1.0.6: resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + /cookie-signature@1.2.1: + resolution: {integrity: sha512-78KWk9T26NhzXtuL26cIJ8/qNHANyJ/ZYrmEXFzUmhZdjpBv+DlWlOANRTGBt48YcyslsLrj0bMLFTmXvLRCOw==} + engines: {node: '>=6.6.0'} + dev: false + /cookie@0.3.1: resolution: {integrity: sha512-+IJOX0OqlHCszo2mBUq+SrEbCj6w7Kpffqx60zYbPTFaO4+yYgRjHwcZNpWvaTylDHaV7PPmBHzSecZiMhtPgw==} engines: {node: '>= 0.6'} @@ -8763,8 +8760,8 @@ packages: iconv-lite: 0.4.24 tmp: 0.0.33 - /fast-content-type-parse@1.0.0: - resolution: {integrity: sha512-Xbc4XcysUXcsP5aHUU7Nq3OwvHq97C+WnbkeIefpeYLX+ryzFJlU6OStFJhs6Ol0LkUGpcK+wL0JwfM+FCU5IA==} + /fast-content-type-parse@1.1.0: + resolution: {integrity: sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==} dev: false /fast-copy@3.0.1: @@ -8796,35 +8793,18 @@ packages: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} dev: true - /fast-json-stringify@5.7.0: - resolution: {integrity: sha512-sBVPTgnAZseLu1Qgj6lUbQ0HfjFhZWXAmpZ5AaSGkyLh5gAXBga/uPJjQPHpDFjC9adWIpdOcCLSDTgrZ7snoQ==} + /fast-json-stringify@5.9.1: + resolution: {integrity: sha512-NMrf+uU9UJnTzfxaumMDXK1NWqtPCfGoM9DYIE+ESlaTQqjlANFBy0VAbsm6FB88Mx0nceyi18zTo5kIEUlzxg==} dependencies: '@fastify/deepmerge': 1.3.0 ajv: 8.12.0 ajv-formats: 2.1.1(ajv@8.12.0) fast-deep-equal: 3.1.3 fast-uri: 2.2.0 + json-schema-ref-resolver: 1.0.1 rfdc: 1.3.0 dev: false - /fast-jwt@2.2.3: - resolution: {integrity: sha512-ziANDWUZpgUyE+A8YAkauVnGa/XXJGEXC1H3qXAYnT8v4Et3EsC8Zuvw8ljiqDgRearw9Wy+Q/Miw5x1XmPJTA==} - engines: {node: '>=14 <22'} - dependencies: - asn1.js: 5.4.1 - ecdsa-sig-formatter: 1.0.11 - mnemonist: 0.39.5 - dev: false - - /fast-jwt@3.1.1: - resolution: {integrity: sha512-c6gqmiMU9kUIMs0XcsnnpBMA4A+zi/XULA47r6hYoLR7s1teJ+LwviwZdttCeTZ+F5ZuHlRigNe98C4qN6h4pw==} - engines: {node: '>=14 <22'} - dependencies: - asn1.js: 5.4.1 - ecdsa-sig-formatter: 1.0.11 - mnemonist: 0.39.5 - dev: false - /fast-jwt@3.3.2: resolution: {integrity: sha512-H+JYxaFy2LepiC1AQWM/2hzKlQOWaWUkEnu/yebhYu4+ameb3qG77WiRZ1Ct6YBk6d/ESsNguBfTT5+q0XMtKg==} engines: {node: '>=16 <22'} @@ -8864,20 +8844,20 @@ packages: reusify: 1.0.4 dev: false - /fastify-auth0-verify@1.2.0: - resolution: {integrity: sha512-Xlc0hnaY9129zaP2bqTozRKVr//6YtHoqcfssXmT0IHxz5GdnCrt7cBak+WJrrBO2H9Rutbdz/q4mW6MbM7lKg==} + /fastify-auth0-verify@1.2.1: + resolution: {integrity: sha512-1f7/9cNxwwQLEjkdV63AOkS23zNrPpt10JpDLxycV7nInYltnyneGADONWZGtSdtB5UwxTEcgqUBprl50H2suA==} engines: {node: '>= 14.0.0'} dependencies: - '@fastify/cookie': 8.3.0 - '@fastify/jwt': 7.0.0 + '@fastify/cookie': 9.2.0 + '@fastify/jwt': 7.2.4 fastify-jwt-jwks: 1.1.4 - fastify-plugin: 4.5.0 + fastify-plugin: 4.5.1 transitivePeerDependencies: - encoding dev: false - /fastify-cli@5.7.1: - resolution: {integrity: sha512-0FgQux1TK+zsRxVLDCF4F+x2ilHDeaJB2TouHIHWRS0+cM6aC+bBSqHva/myrlZs/1lQCAe294DvZkAuJyHySw==} + /fastify-cli@5.9.0: + resolution: {integrity: sha512-CaIte5SwkLuvlzpdd1Al1VRVVwm2TQVV4bfVP4oz/Z54KVSo6pqNTgnxWOmyzdcNUbFnhJ3Z4vRjzvHoymP5cQ==} hasBin: true dependencies: '@fastify/deepmerge': 1.3.0 @@ -8886,13 +8866,13 @@ packages: close-with-grace: 1.2.0 commist: 3.2.0 dotenv: 16.3.1 - fastify: 4.18.0 - fastify-plugin: 4.5.0 + fastify: 4.25.1 + fastify-plugin: 4.5.1 generify: 4.2.0 help-me: 4.2.0 is-docker: 2.2.1 make-promises-safe: 5.1.0 - pino-pretty: 9.4.0 + pino-pretty: 10.3.0 pkg-up: 3.1.0 resolve-from: 5.0.0 semver: 7.5.4 @@ -8914,7 +8894,7 @@ packages: dependencies: '@fastify/cookie': 8.3.0 '@fastify/jwt': 7.2.4 - fastify-plugin: 4.5.0 + fastify-plugin: 4.5.1 http-errors: 2.0.0 node-cache: 5.1.2 node-fetch: 2.6.11 @@ -8922,34 +8902,42 @@ packages: - encoding dev: false - /fastify-plugin@4.5.0: - resolution: {integrity: sha512-79ak0JxddO0utAXAQ5ccKhvs6vX2MGyHHMMsmZkBANrq3hXc1CHzvNPHOcvTsVMEPl5I+NT+RO4YKMGehOfSIg==} + /fastify-overview@3.6.0: + resolution: {integrity: sha512-pRoagnJ4ojuWuibJfG73kElwx2UGHEdJ6j/OeomOHBQR8Fjvqvt06El/F3HF+lm5d6oeBvad+dc3oiEfPYXZyQ==} + engines: {node: '>=14'} + dependencies: + fastify-plugin: 4.5.1 + object-hash: 2.2.0 + dev: false + + /fastify-plugin@4.5.1: + resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==} dev: false - /fastify-tsconfig@1.0.1: - resolution: {integrity: sha512-BXkTG3JYcjJb3xX5R5FcE9ciscV/h7YtmnkiSaNAONd1g6ooMSN/4GWfhA8hnS6SRZFYBBxsn8719Mj9lbCOtA==} - engines: {node: '>=10.4.0'} + /fastify-tsconfig@2.0.0: + resolution: {integrity: sha512-pvYwdtbZUJr/aTD7ZE0rGlvtYpx7IThHKVLBoqCKmT3FJpwm23XA2+PDmq8ZzfqqG4ajpyrHd5bkIixcIFjPhQ==} + engines: {node: '>=18.0.0'} dev: true - /fastify@4.18.0: - resolution: {integrity: sha512-L5o/2GEkBastQ3HV0dtKo7SUZ497Z1+q4fcqAoPyq6JCQ/8zdk1JQEoTQwnBWCp+EmA7AQa6mxNqSAEhzP0RwQ==} + /fastify@4.25.1: + resolution: {integrity: sha512-D8d0rv61TwqoAS7lom2tvIlgVMlx88lLsiwXyWNjA7CU/LC/mx/Gp2WAlC0S/ABq19U+y/aRvYFG5xLUu2aMrg==} dependencies: '@fastify/ajv-compiler': 3.5.0 - '@fastify/error': 3.2.1 + '@fastify/error': 3.4.1 '@fastify/fast-json-stringify-compiler': 4.3.0 abstract-logging: 2.0.1 avvio: 8.2.1 - fast-content-type-parse: 1.0.0 - fast-json-stringify: 5.7.0 - find-my-way: 7.6.2 - light-my-request: 5.10.0 - pino: 8.14.1 - process-warning: 2.2.0 + fast-content-type-parse: 1.1.0 + fast-json-stringify: 5.9.1 + find-my-way: 7.7.0 + light-my-request: 5.11.0 + pino: 8.17.1 + process-warning: 3.0.0 proxy-addr: 2.0.7 rfdc: 1.3.0 secure-json-parse: 2.7.0 semver: 7.5.4 - tiny-lru: 11.0.1 + toad-cache: 3.4.1 transitivePeerDependencies: - supports-color dev: false @@ -9070,8 +9058,8 @@ packages: transitivePeerDependencies: - supports-color - /find-my-way@7.6.2: - resolution: {integrity: sha512-0OjHn1b1nCX3eVbm9ByeEHiscPYiHLfhei1wOUU9qffQkk98wE0Lo8VrVYfSGMgnSnDh86DxedduAnBf4nwUEw==} + /find-my-way@7.7.0: + resolution: {integrity: sha512-+SrHpvQ52Q6W9f3wJoJBbAQULJuNEEQwBvlvYwACDhBTLOTMiQ0HYWh4+vC3OivGP2ENcTI1oKlFA2OepJNjhQ==} engines: {node: '>=14'} dependencies: fast-deep-equal: 3.1.3 @@ -9134,8 +9122,8 @@ packages: resolution: {integrity: sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==} dev: true - /fluent-json-schema@4.1.0: - resolution: {integrity: sha512-7VHxIDorfKLwMirK0RUnmMw7I0eBS1WRgIMaA+DL6rExUXnuEXFlQeiqZ6WHbgWK/OjtbblzxkaSftXMmiAtnQ==} + /fluent-json-schema@4.2.1: + resolution: {integrity: sha512-vSvURY8BBRqxOFquy/wwjdnT4z07j2NZ+Cm3Nj881NHXKPSdiE4ZNyRImDh+SIk2yFZKzj7Clt+ENb5ha4uYJA==} dependencies: '@fastify/deepmerge': 1.3.0 dev: false @@ -9714,6 +9702,10 @@ packages: readable-stream: 3.6.2 dev: false + /help-me@5.0.0: + resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + dev: false + /hey-listen@1.0.8: resolution: {integrity: sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==} @@ -10470,6 +10462,12 @@ packages: /json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + /json-schema-ref-resolver@1.0.1: + resolution: {integrity: sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw==} + dependencies: + fast-deep-equal: 3.1.3 + dev: false + /json-schema-resolver@2.0.0: resolution: {integrity: sha512-pJ4XLQP4Q9HTxl6RVDLJ8Cyh1uitSs0CzDBAz1uoJ4sRD/Bk7cFSXL1FUXDW3zJ7YnfliJx6eu8Jn283bpZ4Yg==} engines: {node: '>=10'} @@ -10587,8 +10585,8 @@ packages: type-check: 0.4.0 dev: true - /light-my-request@5.10.0: - resolution: {integrity: sha512-ZU2D9GmAcOUculTTdH9/zryej6n8TzT+fNGdNtm6SDp5MMMpHrJJkvAdE3c6d8d2chE9i+a//dS9CWZtisknqA==} + /light-my-request@5.11.0: + resolution: {integrity: sha512-qkFCeloXCOMpmEdZ/MV91P8AT4fjwFXWaAFz3lUeStM8RcoM1ks4J/F8r1b3r6y/H4u3ACEJ1T+Gv5bopj7oDA==} dependencies: cookie: 0.5.0 process-warning: 2.2.0 @@ -12044,6 +12042,11 @@ packages: engines: {node: '>=0.10.0'} dev: true + /object-hash@2.2.0: + resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==} + engines: {node: '>= 6'} + dev: false + /object-inspect@1.13.1: resolution: {integrity: sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==} @@ -12517,15 +12520,22 @@ packages: split2: 4.2.0 dev: false - /pino-pretty@9.4.0: - resolution: {integrity: sha512-NIudkNLxnl7MGj1XkvsqVyRgo6meFP82ECXF2PlOI+9ghmbGuBUUqKJ7IZPIxpJw4vhhSva0IuiDSAuGh6TV9g==} + /pino-abstract-transport@1.1.0: + resolution: {integrity: sha512-lsleG3/2a/JIWUtf9Q5gUNErBqwIu1tUKTT3dUzaf5DySw9ra1wcqKjJjLX1VTY64Wk1eEOYsVGSaGfCK85ekA==} + dependencies: + readable-stream: 4.4.0 + split2: 4.2.0 + dev: false + + /pino-pretty@10.3.0: + resolution: {integrity: sha512-JthvQW289q3454mhM3/38wFYGWPiBMR28T3CpDNABzoTQOje9UKS7XCJQSnjWF9LQGQkGd8D7h0oq+qwiM3jFA==} hasBin: true dependencies: colorette: 2.0.20 dateformat: 4.6.3 fast-copy: 3.0.1 fast-safe-stringify: 2.1.1 - help-me: 4.2.0 + help-me: 5.0.0 joycon: 3.1.1 minimist: 1.2.8 on-exit-leak-free: 2.1.0 @@ -12541,20 +12551,20 @@ packages: resolution: {integrity: sha512-wHuWB+CvSVb2XqXM0W/WOYUkVSPbiJb9S5fNB7TBhd8s892Xq910bRxwHtC4l71hgztObTjXL6ZheZXFjhDrDQ==} dev: false - /pino@8.14.1: - resolution: {integrity: sha512-8LYNv7BKWXSfS+k6oEc6occy5La+q2sPwU3q2ljTX5AZk7v+5kND2o5W794FyRaqha6DJajmkNRsWtPpFyMUdw==} + /pino@8.17.1: + resolution: {integrity: sha512-YoN7/NJgnsJ+fkADZqjhRt96iepWBndQHeClmSBH0sQWCb8zGD74t00SK4eOtKFi/f8TUmQnfmgglEhd2kI1RQ==} hasBin: true dependencies: atomic-sleep: 1.0.0 fast-redact: 3.2.0 on-exit-leak-free: 2.1.0 - pino-abstract-transport: 1.0.0 + pino-abstract-transport: 1.1.0 pino-std-serializers: 6.2.1 process-warning: 2.2.0 quick-format-unescaped: 4.0.4 real-require: 0.2.0 safe-stable-stringify: 2.4.3 - sonic-boom: 3.3.0 + sonic-boom: 3.7.0 thread-stream: 2.3.0 dev: false @@ -12790,6 +12800,10 @@ packages: resolution: {integrity: sha512-/1WZ8+VQjR6avWOgHeEPd7SDQmFQ1B5mC1eRXsCm5TarlNmx/wCsa5GEaxGm05BORRtyG/Ex/3xq3TuRvq57qg==} dev: false + /process-warning@3.0.0: + resolution: {integrity: sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==} + dev: false + /process@0.11.10: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} @@ -13970,6 +13984,12 @@ packages: atomic-sleep: 1.0.0 dev: false + /sonic-boom@3.7.0: + resolution: {integrity: sha512-IudtNvSqA/ObjN97tfgNmOKyDOs4dNcg4cUUsHDebqsgb8wGBBwb31LIgShNO8fye0dFI52X1+tFoKKI6Rq1Gg==} + dependencies: + atomic-sleep: 1.0.0 + dev: false + /sort-keys@4.2.0: resolution: {integrity: sha512-aUYIEU/UviqPgc8mHR6IW1EGxkAXpeRETYcrzg8cLAvUPZcpAlleSXHV2mY7G12GphSH6Gzv+4MMVSSkbdteHg==} engines: {node: '>=8'} @@ -14568,11 +14588,6 @@ packages: globrex: 0.1.2 dev: true - /tiny-lru@11.0.1: - resolution: {integrity: sha512-iNgFugVuQgBKrqeO/mpiTTgmBsTP0WL6yeuLfLs/Ctf0pI/ixGqIRm8sDCwMcXGe9WWvt2sGXI5mNqZbValmJg==} - engines: {node: '>=12'} - dev: false - /tinybench@2.5.0: resolution: {integrity: sha512-kRwSG8Zx4tjF9ZiyH4bhaebu+EDz1BOx9hOigYHlUW4xxI/wKIUQUqo018UlU4ar6ATPBsaMrdbKZ+tmPdohFA==} dev: true @@ -14636,6 +14651,11 @@ packages: dependencies: is-number: 7.0.0 + /toad-cache@3.4.1: + resolution: {integrity: sha512-T0m3MxP3wcqW0LaV3dF1mHBU294sgYSm4FOpa5eEJaYO7PqJZBOjZEQI1y4YaKNnih1FXCEYTWDS9osCoTUefg==} + engines: {node: '>=12'} + dev: false + /toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} From b2c8c0f0d7557bf3db561f96d14262ae7e07fcb6 Mon Sep 17 00:00:00 2001 From: riccardoperra Date: Sun, 17 Dec 2023 23:26:28 +0100 Subject: [PATCH 04/16] feat: frontend passkey/auth0 custom provider integration --- apps/api/api-types/index.d.ts | 3 +- .../project/handlers/project.service.ts | 6 +- apps/api/src/plugins/errorHandler.ts | 4 +- .../src/routes/v1/passkey/deleteCredential.ts | 9 +- .../routes/v1/passkey/finalizeRegistration.ts | 41 +++---- .../src/routes/v1/passkey/listCredentials.ts | 38 +++--- .../routes/v1/passkey/startRegistration.ts | 29 ++--- apps/api/src/routes/v1/user/info.ts | 26 +++- apps/api/src/schemas/index.ts | 1 + apps/codeimage/package.json | 3 + .../PresetSwitcher/PresetTooltipContent.tsx | 7 +- .../src/components/Toolbar/LoginDialog.tsx | 27 ++++ .../src/components/Toolbar/Toolbar.tsx | 6 +- .../components/Toolbar/ToolbarSettings.tsx | 5 +- .../components/Toolbar/ToolbarSnippetName.tsx | 6 +- .../src/components/UserBadge/UserBadge.tsx | 29 +++-- apps/codeimage/src/core/constants/auth0.ts | 21 ++++ apps/codeimage/src/data-access/client.ts | 7 +- apps/codeimage/src/data-access/passkey.ts | 39 ++++++ apps/codeimage/src/data-access/user.ts | 14 +++ apps/codeimage/src/index.tsx | 15 +-- .../src/pages/Dashboard/dashboard.state.ts | 10 +- apps/codeimage/src/state/auth/auth.ts | 97 +++++++++++++++ apps/codeimage/src/state/auth/auth0.ts | 26 ++-- .../state/auth/passkey/hankoPasskeyState.ts | 31 +++++ .../state/auth/providers/auth0.provider.ts | 76 ++++++++++++ .../auth/providers/hanko-passkey.provider.ts | 115 ++++++++++++++++++ .../src/state/editor/createEditorSync.ts | 5 +- apps/codeimage/src/state/hanko.ts | 95 +++++++++++++++ apps/codeimage/src/state/presets/bridge.ts | 6 +- apps/codeimage/src/state/presets/presets.ts | 5 +- .../GithubLoginButton/GithubLoginButton.tsx | 11 +- pnpm-lock.yaml | 24 ++++ 33 files changed, 710 insertions(+), 127 deletions(-) create mode 100644 apps/codeimage/src/components/Toolbar/LoginDialog.tsx create mode 100644 apps/codeimage/src/data-access/passkey.ts create mode 100644 apps/codeimage/src/data-access/user.ts create mode 100644 apps/codeimage/src/state/auth/auth.ts create mode 100644 apps/codeimage/src/state/auth/passkey/hankoPasskeyState.ts create mode 100644 apps/codeimage/src/state/auth/providers/auth0.provider.ts create mode 100644 apps/codeimage/src/state/auth/providers/hanko-passkey.provider.ts create mode 100644 apps/codeimage/src/state/hanko.ts diff --git a/apps/api/api-types/index.d.ts b/apps/api/api-types/index.d.ts index 15673f64a..f280b72ba 100644 --- a/apps/api/api-types/index.d.ts +++ b/apps/api/api-types/index.d.ts @@ -14,4 +14,5 @@ export type { PasskeyFinalizeRegistrationApi, PasskeyStartLoginApi, PasskeyFinalizeLoginApi, -} from '../dist/schemas/index.js'; + UserInfoApi, +} from '../src/schemas/index'; diff --git a/apps/api/src/modules/project/handlers/project.service.ts b/apps/api/src/modules/project/handlers/project.service.ts index 0456941b1..ddbd998e0 100644 --- a/apps/api/src/modules/project/handlers/project.service.ts +++ b/apps/api/src/modules/project/handlers/project.service.ts @@ -1,5 +1,5 @@ import {Project, User} from '@codeimage/prisma-models'; -import {HttpError, HttpErrors} from '@fastify/sensible/lib/httpError.js'; +import {HttpErrors} from '@fastify/sensible/lib/httpError.js'; import {createProjectRequestMapper} from '../mapper/create-project-mapper.js'; import {createCompleteProjectGetByIdResponseMapper} from '../mapper/get-project-by-id-mapper.js'; import {ProjectRepository} from '../repository/index.js'; @@ -89,7 +89,7 @@ export function makeProjectService( try { const project = await repository.findById(projectId); if (!project) { - throw {name: 'NotFoundError'} as HttpError; + throw {name: 'NotFoundError'} as HttpErrors['HttpError']; } return this.createNewProject(user.id, { name: newName ?? project.name, @@ -99,7 +99,7 @@ export function makeProjectService( terminal: project.terminal, }); } catch (e) { - const error = e as HttpError; + const error = e as HttpErrors['HttpError']; if (error && error.name === 'NotFoundError') { throw httpErrors.notFound( `Cannot clone project with id ${projectId} since it does not exists`, diff --git a/apps/api/src/plugins/errorHandler.ts b/apps/api/src/plugins/errorHandler.ts index 0bea9c0d6..2c55ddf7e 100644 --- a/apps/api/src/plugins/errorHandler.ts +++ b/apps/api/src/plugins/errorHandler.ts @@ -1,11 +1,11 @@ -import {HttpError} from '@fastify/sensible/lib/httpError.js'; +import {HttpErrors} from '@fastify/sensible/lib/httpError.js'; import fp from 'fastify-plugin'; import {NotFoundEntityException} from '../common/exceptions/NotFoundEntityException.js'; export default fp( async fastify => { fastify.setErrorHandler((error, request, reply) => { - let httpError: HttpError | null = null; + let httpError: HttpErrors['HttpError'] | null = null; if (error.statusCode) { httpError = fastify.httpErrors.createError( diff --git a/apps/api/src/routes/v1/passkey/deleteCredential.ts b/apps/api/src/routes/v1/passkey/deleteCredential.ts index 19ad0b2f7..ad5301c3c 100644 --- a/apps/api/src/routes/v1/passkey/deleteCredential.ts +++ b/apps/api/src/routes/v1/passkey/deleteCredential.ts @@ -2,6 +2,10 @@ import {FastifyPluginAsyncTypebox} from '@fastify/type-provider-typebox'; import {Type} from '@sinclair/typebox'; const route: FastifyPluginAsyncTypebox = async fastify => { + fastify.addHook( + 'preValidation', + fastify.authorize({mustBeAuthenticated: true}), + ); fastify.delete( '/credentials', { @@ -10,11 +14,6 @@ const route: FastifyPluginAsyncTypebox = async fastify => { credentialId: Type.String(), }), }, - preValidation: function (request, reply, done) { - return fastify - .auth([fastify.authenticate, fastify.verifyHankoPasskey]) - .apply(this, [request, reply, done]); - }, }, async request => { return fastify.passkeysApi.credential(request.params.credentialId); diff --git a/apps/api/src/routes/v1/passkey/finalizeRegistration.ts b/apps/api/src/routes/v1/passkey/finalizeRegistration.ts index 0ad47ecd7..aaa48025f 100644 --- a/apps/api/src/routes/v1/passkey/finalizeRegistration.ts +++ b/apps/api/src/routes/v1/passkey/finalizeRegistration.ts @@ -47,29 +47,26 @@ const schema = { export type PasskeyFinalizeRegistrationApi = GetApiTypes; const route: FastifyPluginAsyncTypebox = async fastify => { - fastify.post( - '/finalize-registration', - { - schema, - preValidation: (req, reply) => fastify.authorize(req, reply), - }, - async request => { - // const {appUser} = request; - return fastify.passkeysApi.registration.finalize({ - rawId: request.body.rawId, - type: request.body.type, - transports: request.body.transports, - authenticatorAttachment: request.body.authenticatorAttachment, - id: request.body.id, - clientExtensionResults: request.body.clientExtensionResults, - response: { - transports: request.body.response.transports, - clientDataJSON: request.body.response.clientDataJSON, - attestationObject: request.body.response.attestationObject, - }, - }); - }, + fastify.addHook( + 'preValidation', + fastify.authorize({mustBeAuthenticated: true}), ); + fastify.post('/finalize-registration', {schema}, async request => { + // const {appUser} = request; + return fastify.passkeysApi.registration.finalize({ + rawId: request.body.rawId, + type: request.body.type, + transports: request.body.transports, + authenticatorAttachment: request.body.authenticatorAttachment, + id: request.body.id, + clientExtensionResults: request.body.clientExtensionResults, + response: { + transports: request.body.response.transports, + clientDataJSON: request.body.response.clientDataJSON, + attestationObject: request.body.response.attestationObject, + }, + }); + }); }; export default route; diff --git a/apps/api/src/routes/v1/passkey/listCredentials.ts b/apps/api/src/routes/v1/passkey/listCredentials.ts index 5caf90b70..d9587f073 100644 --- a/apps/api/src/routes/v1/passkey/listCredentials.ts +++ b/apps/api/src/routes/v1/passkey/listCredentials.ts @@ -1,27 +1,25 @@ import {FastifyPluginAsyncTypebox} from '@fastify/type-provider-typebox'; const route: FastifyPluginAsyncTypebox = async fastify => { - fastify.get( - '/credentials', - { - preValidation: (request, reply) => fastify.authenticate(request, reply), - }, - async () => { - return fetch( - `https://passkeys.hanko.io/${fastify.config.HANKO_PASSKEYS_TENANT_ID}/credentials?user_id=4676fe25-3660-4c0d-b89e-34b177e759f0`, - { - headers: { - apikey: fastify.config.HANKO_PASSKEYS_API_KEY, - }, - }, - ) - .then(s => s.json()) - .then(s => { - console.log(s); - return s; - }); - }, + fastify.addHook( + 'preValidation', + fastify.authorize({mustBeAuthenticated: true}), ); + fastify.get('/credentials', {}, async () => { + return fetch( + `https://passkeys.hanko.io/${fastify.config.HANKO_PASSKEYS_TENANT_ID}/credentials?user_id=4676fe25-3660-4c0d-b89e-34b177e759f0`, + { + headers: { + apikey: fastify.config.HANKO_PASSKEYS_API_KEY, + }, + }, + ) + .then(s => s.json()) + .then(s => { + console.log(s); + return s; + }); + }); }; export default route; diff --git a/apps/api/src/routes/v1/passkey/startRegistration.ts b/apps/api/src/routes/v1/passkey/startRegistration.ts index d0fc94455..c0bac52d8 100644 --- a/apps/api/src/routes/v1/passkey/startRegistration.ts +++ b/apps/api/src/routes/v1/passkey/startRegistration.ts @@ -58,23 +58,20 @@ const schema = { export type PasskeyStartRegistrationApi = GetApiTypes; const route: FastifyPluginAsyncTypebox = async fastify => { - fastify.post( - '/registration', - { - preValidation: (req, reply) => fastify.authorize(req, reply), - schema, - }, - async request => { - const {appUser} = request; - fastify.log.info( - `Init passkey registration for user with id ${appUser.id}`, - ); - return fastify.passkeysApi.registration.initialize({ - userId: appUser.id, - username: appUser.email, - }); - }, + fastify.addHook( + 'preValidation', + fastify.authorize({mustBeAuthenticated: true}), ); + fastify.post('/registration', {schema}, async request => { + const {appUser} = request; + fastify.log.info( + `Init passkey registration for user with id ${appUser.id}`, + ); + return fastify.passkeysApi.registration.initialize({ + userId: appUser.id, + username: appUser.email, + }); + }); }; export default route; diff --git a/apps/api/src/routes/v1/user/info.ts b/apps/api/src/routes/v1/user/info.ts index 5e1447055..d72cce0c0 100644 --- a/apps/api/src/routes/v1/user/info.ts +++ b/apps/api/src/routes/v1/user/info.ts @@ -1,16 +1,38 @@ import {FastifyPluginAsyncTypebox} from '@fastify/type-provider-typebox'; +import {Static, Type} from '@sinclair/typebox'; +import {FastifySchema} from 'fastify'; +import {GetApiTypes} from '../../../common/types/extract-api-types.js'; + +const UserInfoSchema = Type.Object({ + id: Type.String(), + user_id: Type.String(), + email: Type.String(), + created_at: Type.String({format: 'date-time'}), + email_verified: Type.Boolean(), + picture: Type.String(), +}); + +const schema = { + response: { + 200: UserInfoSchema, + }, +} satisfies FastifySchema; + +export type UserInfoApi = GetApiTypes; const route: FastifyPluginAsyncTypebox = async fastify => { fastify.addHook( 'preValidation', fastify.authorize({mustBeAuthenticated: true}), ); - fastify.get('/info', {}, async request => { + fastify.get('/info', {schema}, async request => { const response = await fastify.auth0Management.usersByEmail.getByEmail({ email: request.appUser.email, fields: 'user_id,email,created_at,email_verified,picture', }); - return {...response.data[0], id: request.appUser.id}; + return {...response.data[0], id: request.appUser.id} as Static< + typeof UserInfoSchema + >; }); }; diff --git a/apps/api/src/schemas/index.ts b/apps/api/src/schemas/index.ts index b1d08ad49..0992d909d 100644 --- a/apps/api/src/schemas/index.ts +++ b/apps/api/src/schemas/index.ts @@ -15,3 +15,4 @@ export type {PasskeyStartRegistrationApi} from '../routes/v1/passkey/startRegist export type {PasskeyFinalizeRegistrationApi} from '../routes/v1/passkey/finalizeRegistration.js'; export type {PasskeyStartLoginApi} from '../routes/v1/passkey/startLogin.js'; export type {PasskeyFinalizeLoginApi} from '../routes/v1/passkey/finalizeLogin.js'; +export type {UserInfoApi} from '../routes/v1/user/info.js'; diff --git a/apps/codeimage/package.json b/apps/codeimage/package.json index 7a2041e91..07947f791 100644 --- a/apps/codeimage/package.json +++ b/apps/codeimage/package.json @@ -71,6 +71,7 @@ "@floating-ui/core": "^1.2.2", "@floating-ui/dom": "^1.2.3", "@formatjs/intl-relativetimeformat": "11.1.4", + "@github/webauthn-json": "2.1.1", "@kobalte/core": "^0.11.0", "@kobalte/utils": "^0.9.0", "@kobalte/vanilla-extract": "^0.4.0", @@ -89,6 +90,7 @@ "@solid-primitives/platform": "^0.0.101", "@solid-primitives/props": "^2.2.2", "@solid-primitives/resize-observer": "^2.0.11", + "@solid-primitives/storage": "2.1.1", "@solid-primitives/utils": "^6.0.0", "@solidjs/router": "^0.8.2", "@thisbeyond/solid-dnd": "0.7.2", @@ -100,6 +102,7 @@ "downloadjs": "^1.4.7", "idb-keyval": "^6.2.0", "inter-ui": "^3.19.3", + "jwt-decode": "4.0.0", "modern-normalize": "^1.1.0", "motion": "^10.15.5", "polished": "^4.2.2", diff --git a/apps/codeimage/src/components/Presets/PresetSwitcher/PresetTooltipContent.tsx b/apps/codeimage/src/components/Presets/PresetSwitcher/PresetTooltipContent.tsx index 5a5d0ae52..1e9955308 100644 --- a/apps/codeimage/src/components/Presets/PresetSwitcher/PresetTooltipContent.tsx +++ b/apps/codeimage/src/components/Presets/PresetSwitcher/PresetTooltipContent.tsx @@ -1,10 +1,11 @@ import {useI18n} from '@codeimage/locale'; -import {getAuth0State} from '@codeimage/store/auth/auth0'; +import {AuthState} from '@codeimage/store/auth/auth'; +import {provideAppState} from '@codeimage/store/index'; import {Box, Link} from '@codeimage/ui'; import {AppLocaleEntries} from '../../../i18n'; export function PresetTooltipContent() { - const {loggedIn, login} = getAuth0State(); + const {loggedIn, openLoginPopup} = provideAppState(AuthState); const [t] = useI18n(); return ( <> @@ -16,7 +17,7 @@ export function PresetTooltipContent() {

+ + + + + + + + + ); +} diff --git a/apps/codeimage/src/components/Toolbar/Toolbar.tsx b/apps/codeimage/src/components/Toolbar/Toolbar.tsx index b902b1a78..8c9872e4a 100644 --- a/apps/codeimage/src/components/Toolbar/Toolbar.tsx +++ b/apps/codeimage/src/components/Toolbar/Toolbar.tsx @@ -1,6 +1,7 @@ -import {getAuth0State} from '@codeimage/store/auth/auth0'; +import {AuthState} from '@codeimage/store/auth/auth'; import {getRootEditorStore} from '@codeimage/store/editor'; import {getEditorSyncAdapter} from '@codeimage/store/editor/createEditorSync'; +import {provideAppState} from '@codeimage/store/index'; import {getThemeStore} from '@codeimage/store/theme/theme.store'; import {backgroundColorVar, Box, colorVar, HStack} from '@codeimage/ui'; import {As, buttonStyles, Link} from '@codeui/kit'; @@ -25,8 +26,9 @@ interface ToolbarProps { export function Toolbar(props: VoidProps) { const modality = useModality(); const editor = getRootEditorStore(); + const authState = provideAppState(AuthState); const {themeArray: themes} = getThemeStore(); - const loggedIn = () => getAuth0State().loggedIn(); + const loggedIn = () => authState.loggedIn(); const isRemote = () => !!getEditorSyncAdapter()?.snippetId(); const themeConfiguration = createMemo( diff --git a/apps/codeimage/src/components/Toolbar/ToolbarSettings.tsx b/apps/codeimage/src/components/Toolbar/ToolbarSettings.tsx index 7c96f6adb..f1a0cc362 100644 --- a/apps/codeimage/src/components/Toolbar/ToolbarSettings.tsx +++ b/apps/codeimage/src/components/Toolbar/ToolbarSettings.tsx @@ -1,4 +1,5 @@ -import {getAuth0State} from '@codeimage/store/auth/auth0'; +import {AuthState} from '@codeimage/store/auth/auth'; +import {provideAppState} from '@codeimage/store/index'; import { As, @@ -20,7 +21,7 @@ import {SettingsDialog} from './SettingsDialog'; export function ToolbarSettingsButton() { const navigate = useNavigate(); const openDialog = createControlledDialog(); - const {signOut, loggedIn} = getAuth0State(); + const {signOut, loggedIn} = provideAppState(AuthState); return ( diff --git a/apps/codeimage/src/components/Toolbar/ToolbarSnippetName.tsx b/apps/codeimage/src/components/Toolbar/ToolbarSnippetName.tsx index 1b8027bc1..ea662d681 100644 --- a/apps/codeimage/src/components/Toolbar/ToolbarSnippetName.tsx +++ b/apps/codeimage/src/components/Toolbar/ToolbarSnippetName.tsx @@ -1,5 +1,6 @@ -import {getAuth0State} from '@codeimage/store/auth/auth0'; +import {AuthState} from '@codeimage/store/auth/auth'; import {getEditorSyncAdapter} from '@codeimage/store/editor/createEditorSync'; +import {provideAppState} from '@codeimage/store/index'; import {Box, FlexField, HStack, LoadingCircle, Text} from '@codeimage/ui'; import {TextField} from '@codeui/kit'; import clickOutside from '@core/directives/clickOutside'; @@ -14,8 +15,9 @@ import * as styles from './Toolbar.css'; void clickOutside; export function ToolbarSnippetName() { + const authState = provideAppState(AuthState); const [editing, setEditing] = createSignal(false); - const loggedIn = () => getAuth0State().loggedIn(); + const loggedIn = () => authState.loggedIn(); const {remoteSync, activeWorkspace, readOnly} = getEditorSyncAdapter()!; const [value, setValue] = createSignal(activeWorkspace()?.name || undefined); createEffect( diff --git a/apps/codeimage/src/components/UserBadge/UserBadge.tsx b/apps/codeimage/src/components/UserBadge/UserBadge.tsx index a149088ed..86e839ac6 100644 --- a/apps/codeimage/src/components/UserBadge/UserBadge.tsx +++ b/apps/codeimage/src/components/UserBadge/UserBadge.tsx @@ -1,25 +1,31 @@ -import {getAuth0State} from '@codeimage/store/auth/auth0'; +import {AuthState} from '@codeimage/store/auth/auth'; +import {provideAppState} from '@codeimage/store/index'; import {Badge} from '@codeimage/ui'; import { As, + Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuTrigger, } from '@codeui/kit'; -import {GithubLoginButton} from '@ui/GithubLoginButton/GithubLoginButton'; +import {createControlledDialog} from '@core/hooks/createControlledDialog'; import {Show} from 'solid-js'; +import {LoginDialog} from '../Toolbar/LoginDialog'; import * as styles from './UserBadge.css'; export function UserBadge() { - const {loggedIn, user, signOut} = getAuth0State(); + const authState = provideAppState(AuthState); + const user = () => authState().user; const profileImage = () => user()?.picture; + const openDialog = createControlledDialog(); + const initials = () => { - const $user = user(); - if (!$user) return; - const [first = '', last = ''] = ($user.name ?? '').split(' '); + const userValue = user(); + if (!userValue) return; + const [first = '', last = ''] = (userValue.name ?? '').split(' '); return [first, last] .filter(data => !!data) .map(data => data[0]) @@ -27,7 +33,14 @@ export function UserBadge() { }; return ( - } when={loggedIn()}> + openDialog(LoginDialog, {})}> + Login + + } + when={authState.loggedIn()} + > @@ -40,7 +53,7 @@ export function UserBadge() { - signOut()}> + authState.signOut()}> Logout diff --git a/apps/codeimage/src/core/constants/auth0.ts b/apps/codeimage/src/core/constants/auth0.ts index 44e40c3c8..78f1e4080 100644 --- a/apps/codeimage/src/core/constants/auth0.ts +++ b/apps/codeimage/src/core/constants/auth0.ts @@ -22,4 +22,25 @@ function createAuth0(): Promise { ); } +export function createAuth0Client(): Promise { + if (env.VITE_MOCK_AUTH) { + return import('./auth0Mock').then(({createAuth0Client}) => + createAuth0Client(), + ); + } + return import('@auth0/auth0-spa-js').then( + ({Auth0Client}) => + new Auth0Client({ + domain: env.VITE_PUBLIC_AUTH0_DOMAIN, + clientId: env.VITE_PUBLIC_AUTH0_CLIENT_ID, + authorizationParams: { + redirect_uri: `${window.location.protocol}//${window.location.host}`, + audience: env.VITE_PUBLIC_AUTH0_AUDIENCE, + }, + cookieDomain: 'codeimage.dev', + cacheLocation: 'localstorage', + }), + ); +} + export const auth0 = createAuth0(); diff --git a/apps/codeimage/src/data-access/client.ts b/apps/codeimage/src/data-access/client.ts index e8874c92d..2ede1dde6 100644 --- a/apps/codeimage/src/data-access/client.ts +++ b/apps/codeimage/src/data-access/client.ts @@ -1,4 +1,6 @@ +import {AuthState} from '@codeimage/store/auth/auth'; import {getAuth0State} from '@codeimage/store/auth/auth0'; +import {provideAppState} from '@codeimage/store/index'; export interface RequestParams { body?: unknown; @@ -16,7 +18,8 @@ export async function makeFetch( input: RequestInfo, requestParams: Omit & RequestParams, ): Promise { - const {getToken, forceLogin, loggedIn} = getAuth0State(); + const {forceLogin} = getAuth0State(); + const {getToken, loggedIn} = provideAppState(AuthState); let url = typeof input === 'string' ? input : input.url; const headers = new Headers(); @@ -52,7 +55,7 @@ export async function makeFetch( if (requestParams.headers) { for (const [key, value] of Object.entries(requestParams.headers)) { if (value) { - headers.append(key, value); + headers.set(key, value); } } } diff --git a/apps/codeimage/src/data-access/passkey.ts b/apps/codeimage/src/data-access/passkey.ts new file mode 100644 index 000000000..5821aeb6f --- /dev/null +++ b/apps/codeimage/src/data-access/passkey.ts @@ -0,0 +1,39 @@ +import type * as ApiTypes from '@codeimage/api/api-types'; +import {makeFetch} from './client'; + +const env = import.meta.env; +const BASE_URL = env.VITE_API_BASE_URL ?? ''; + +export async function startPasskeyRegistration(): Promise< + ApiTypes.PasskeyStartRegistrationApi['response'] +> { + return makeFetch(`${BASE_URL}/api/v1/passkey/registration`, { + method: 'POST', + }).then(res => res.json()); +} + +export async function finalizePasskeyRegistration( + body: ApiTypes.PasskeyFinalizeRegistrationApi['request']['body'], +): Promise { + return makeFetch(`${BASE_URL}/api/v1/passkey/finalize-registration`, { + method: 'POST', + body, + }).then(res => res.json()); +} + +export async function startPasskeyLogin(): Promise< + ApiTypes.PasskeyStartLoginApi['response'] +> { + return makeFetch(`${BASE_URL}/api/v1/passkey/start-login`, { + method: 'POST', + }).then(res => res.json()); +} + +export async function finalizePasskeyLogin( + body: ApiTypes.PasskeyFinalizeLoginApi['request']['body'], +): Promise { + return makeFetch(`${BASE_URL}/api/v1/passkey/finalize-login`, { + method: 'POST', + body, + }).then(res => res.json()); +} diff --git a/apps/codeimage/src/data-access/user.ts b/apps/codeimage/src/data-access/user.ts new file mode 100644 index 000000000..e1a256e04 --- /dev/null +++ b/apps/codeimage/src/data-access/user.ts @@ -0,0 +1,14 @@ +import {makeFetch} from './client'; +import type * as ApiTypes from '@codeimage/api/api-types'; + +const env = import.meta.env; +const BASE_URL = env.VITE_API_BASE_URL ?? ''; + +export type UserInfoResponse = ApiTypes.UserInfoApi['response']; +export function getUserInfo(token: string): Promise { + return makeFetch(`${BASE_URL}/api/v1/user/info`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }).then(response => response.json()); +} diff --git a/apps/codeimage/src/index.tsx b/apps/codeimage/src/index.tsx index 221db237d..e9a279622 100644 --- a/apps/codeimage/src/index.tsx +++ b/apps/codeimage/src/index.tsx @@ -1,7 +1,8 @@ import {createI18nContext, I18nContext, useI18n} from '@codeimage/locale'; -import {getAuth0State} from '@codeimage/store/auth/auth0'; +import {AuthState} from '@codeimage/store/auth/auth'; import {getRootEditorStore} from '@codeimage/store/editor'; import {EditorConfigStore} from '@codeimage/store/editor/config.store'; +import {provideAppState} from '@codeimage/store/index'; import {getThemeStore} from '@codeimage/store/theme/theme.store'; import {getUiStore} from '@codeimage/store/ui'; import {VersionStore} from '@codeimage/store/version/version.store'; @@ -80,7 +81,7 @@ export function Bootstrap() { getRootEditorStore(); const [, {locale}] = useI18n(); const uiStore = getUiStore(); - const auth0 = getAuth0State(); + const authState = provideAppState(AuthState); createEffect(on(() => uiStore.get.locale, locale)); const mode = () => uiStore.currentTheme(); @@ -88,7 +89,7 @@ export function Bootstrap() { { path: '', component: () => { - const state = getAuth0State(); + const state = provideState(AuthState); return ( } when={state.loggedIn()}> @@ -112,10 +113,10 @@ export function Bootstrap() { { path: 'login', data: ({navigate}) => { - if (auth0.loggedIn()) { + if (authState.loggedIn()) { navigate('/'); } else { - auth0.login(); + authState.login(); } }, }, @@ -155,8 +156,8 @@ export function Bootstrap() { ); } -getAuth0State() - .initLogin() +provideAppState(AuthState) + .init() .catch(() => null) .then(() => { render( diff --git a/apps/codeimage/src/pages/Dashboard/dashboard.state.ts b/apps/codeimage/src/pages/Dashboard/dashboard.state.ts index fcdff5579..ebf31c8bb 100644 --- a/apps/codeimage/src/pages/Dashboard/dashboard.state.ts +++ b/apps/codeimage/src/pages/Dashboard/dashboard.state.ts @@ -1,5 +1,4 @@ import type * as ApiTypes from '@codeimage/api/api-types'; -import {getAuth0State} from '@codeimage/store/auth/auth0'; import {getInitialEditorUiOptions} from '@codeimage/store/editor/editor'; import {getInitialFrameState} from '@codeimage/store/editor/frame'; import {getInitialTerminalState} from '@codeimage/store/editor/terminal'; @@ -10,7 +9,7 @@ import {createContextProvider} from '@solid-primitives/context'; import {createEffect, createResource, createSignal} from 'solid-js'; import {API} from '../../data-access/api'; -function makeDashboardState(authState = getAuth0State()) { +function makeDashboardState() { const [data, {mutate, refetch}] = createResource(fetchWorkspaceContent, { initialValue: [], }); @@ -42,8 +41,6 @@ function makeDashboardState(authState = getAuth0State()) { async function fetchWorkspaceContent(): Promise< ApiTypes.GetProjectByIdApi['response'][] > { - const userId = authState.user()?.id; - if (!userId) return []; return API.project.getWorkspaceContent(); } @@ -96,8 +93,7 @@ function makeDashboardState(authState = getAuth0State()) { oldName: string, newName: string | undefined, ) { - const userId = authState.user()?.id; - if (!userId || !newName || oldName === newName) { + if (!newName || oldName === newName) { return; } mutate(items => @@ -123,8 +119,6 @@ function makeDashboardState(authState = getAuth0State()) { } function cloneProject(project: ApiTypes.GetProjectByIdApi['response']) { - const userId = authState.user()?.id; - if (!userId) return; return API.project.cloneSnippet(project.id, { body: { newName: `${project.name} (copy)`, diff --git a/apps/codeimage/src/state/auth/auth.ts b/apps/codeimage/src/state/auth/auth.ts new file mode 100644 index 000000000..e0def98ce --- /dev/null +++ b/apps/codeimage/src/state/auth/auth.ts @@ -0,0 +1,97 @@ +import {Auth0Provider} from '@codeimage/store/auth/providers/auth0.provider'; +import {HankoPasskeyAuthProvider} from '@codeimage/store/auth/providers/hanko-passkey.provider'; +import {auth0, createAuth0Client} from '@core/constants/auth0'; +import {createControlledDialog} from '@core/hooks/createControlledDialog'; +import {defineSignal} from 'statebuilder'; +import {LoginDialog} from '../../components/Toolbar/LoginDialog'; +import {UserInfoResponse} from '../../data-access/user'; + +export interface AuthState { + user: UserInfoResponse | null; + strategy: 'auth0' | 'passkey' | null; +} + +export const AuthState = defineSignal(() => ({ + user: null, + strategy: null, +})).extend(_ => { + const providers = {} as { + hankoPasskey: HankoPasskeyAuthProvider; + auth0: Auth0Provider; + }; + + async function init() { + // Init providers + await Promise.all([ + createAuth0Client().then(client => new Auth0Provider(client)), + import('./providers/hanko-passkey.provider').then( + m => new m.HankoPasskeyAuthProvider(), + ), + ]).then(([auth0Provider, hankoPasskeyProvider]) => { + providers.hankoPasskey = hankoPasskeyProvider; + providers.auth0 = auth0Provider; + return; + }); + + // Determine which provider to use thanks to session; + // TODO: fix + const strategy: 'passkey' | 'auth0' = localStorage.getItem( + 'auth_strategy', + ) as any; + + let user: UserInfoResponse | undefined; + if (strategy === 'passkey') { + user = await providers.hankoPasskey.init(); + } else if (strategy === 'auth0') { + user = await providers.auth0.init(); + } + _.set(value => ({ + ...value, + user: user ?? null, + })); + } + + const currentProvider = () => { + const strategy = localStorage.getItem('auth_strategy'); + switch (strategy) { + case 'auth0': + return providers.auth0; + case 'passkey': + return providers.hankoPasskey; + default: + throw new Error('Auth provider is not present'); + } + }; + + return { + init, + getToken: () => currentProvider().getJwt(), + loggedIn: () => _().user, + signOut: async () => { + await currentProvider().logout(); + localStorage.removeItem('auth_strategy'); + }, + openLoginPopup() { + const openDialog = createControlledDialog(); + return openDialog(LoginDialog, {}); + }, + providers: { + auth0: { + loginWithGithub: () => + providers.auth0.login().then(() => { + localStorage.setItem('auth_strategy', 'auth0'); + }), + }, + hanko: { + login: async () => { + const detail = await providers.hankoPasskey.login(); + localStorage.setItem('auth_strategy', 'passkey'); + if (!detail) { + return; + } + window.location.reload(); + }, + }, + }, + }; +}); diff --git a/apps/codeimage/src/state/auth/auth0.ts b/apps/codeimage/src/state/auth/auth0.ts index adabd5d07..7bc73d5d5 100644 --- a/apps/codeimage/src/state/auth/auth0.ts +++ b/apps/codeimage/src/state/auth/auth0.ts @@ -1,33 +1,33 @@ import {Auth0Client, User} from '@auth0/auth0-spa-js'; -import {auth0 as $auth0} from '@core/constants/auth0'; +import {auth0} from '@core/constants/auth0'; import {createRoot, createSignal} from 'solid-js'; type AuthState = User | null; -export function $auth0State() { - let auth0!: Auth0Client; +function $auth0State() { + let client!: Auth0Client; const [state, setState] = createSignal(); async function initLogin() { - auth0 = await $auth0; + client = await auth0; const queryParams = new URLSearchParams(window.location.search); - if (!auth0) return; + if (!client) return; if (queryParams.has('code') && queryParams.has('state')) { - const data = await auth0.handleRedirectCallback().catch(() => null); - setState(await auth0.getUser()); + const data = await client.handleRedirectCallback().catch(() => null); + setState(await client.getUser()); history.replaceState(data?.appState, '', window.location.origin); if (data) { // should always be null? } } else { - if (await auth0.isAuthenticated()) { - setState(await auth0.getUser()); + if (await client.isAuthenticated()) { + setState(await client.getUser()); } } } async function login() { - auth0.loginWithRedirect({ + client.loginWithRedirect({ authorizationParams: { connection: 'github', }, @@ -35,7 +35,7 @@ export function $auth0State() { } async function forceLogin() { - auth0.loginWithRedirect({ + client.loginWithRedirect({ authorizationParams: { prompt: 'login', connection: 'github', @@ -44,7 +44,7 @@ export function $auth0State() { } async function signOut() { - await auth0.logout({ + await client.logout({ logoutParams: { returnTo: `${window.location.protocol}//${window.location.host}`, }, @@ -52,7 +52,7 @@ export function $auth0State() { } const getToken = () => { - return auth0.getTokenSilently(); + return client.getTokenSilently(); }; const loggedIn = () => !!state(); diff --git a/apps/codeimage/src/state/auth/passkey/hankoPasskeyState.ts b/apps/codeimage/src/state/auth/passkey/hankoPasskeyState.ts new file mode 100644 index 000000000..4a06b758c --- /dev/null +++ b/apps/codeimage/src/state/auth/passkey/hankoPasskeyState.ts @@ -0,0 +1,31 @@ +import {makePersisted} from '@solid-primitives/storage'; +import {createStore} from 'solid-js/store'; + +interface LocalStorage { + session?: any; +} +export function createPasskeyState(key: string) { + // eslint-disable-next-line solid/reactivity + const signal = createStore({}); + const [state, setState] = makePersisted(signal, { + storage: localStorage, + name: key, + serialize(state) { + const data = JSON.stringify(state); + return window.btoa(encodeURI(encodeURIComponent(data))); + }, + deserialize(data) { + try { + const decoded = decodeURIComponent(decodeURI(window.atob(data))); + return JSON.parse(decoded); + } catch (e) { + return null; + } + }, + }); + + return { + value: state, + setState, + }; +} diff --git a/apps/codeimage/src/state/auth/providers/auth0.provider.ts b/apps/codeimage/src/state/auth/providers/auth0.provider.ts new file mode 100644 index 000000000..8ecd2d014 --- /dev/null +++ b/apps/codeimage/src/state/auth/providers/auth0.provider.ts @@ -0,0 +1,76 @@ +import {Auth0Client, User} from '@auth0/auth0-spa-js'; +import {UserInfoResponse} from '../../../data-access/user'; + +type AppState = { + returnTo?: string; + [key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any +}; + +export class Auth0Provider { + constructor(private readonly client: Auth0Client) {} + async init(): Promise { + try { + let user: User | undefined; + if (this.hasAuthParams()) { + const {appState} = await this.client.handleRedirectCallback(); + user = await this.client.getUser(); + this.onRedirectCallback(appState); + } else { + await this.client.checkSession(); + user = await this.client.getUser(); + } + if (!user) { + return undefined; + } + return { + id: user.sub, + email: user.email, + created_at: user.updated_at, + picture: user.picture, + user_id: user.sub, + email_verified: user.email_verified, + } as UserInfoResponse; + } catch (e) {} + } + + async logout(): Promise { + await this.client.logout({ + logoutParams: { + returnTo: `${window.location.protocol}//${window.location.host}`, + }, + }); + } + + async getJwt(): Promise { + // TODO: handle error + return await this.client.getTokenSilently(); + } + + async login() { + return this.client.loginWithRedirect({ + authorizationParams: { + connection: 'github', + }, + }); + } + + private hasAuthParams(): boolean { + const CODE_RE = /[?&]code=[^&]+/; + const STATE_RE = /[?&]state=[^&]+/; + const ERROR_RE = /[?&]error=[^&]+/; + + const searchParams = window.location.search; + return ( + (CODE_RE.test(searchParams) || ERROR_RE.test(searchParams)) && + STATE_RE.test(searchParams) + ); + } + + private onRedirectCallback(appState?: AppState) { + window.history.replaceState( + {}, + document.title, + appState?.returnTo || window.location.pathname, + ); + } +} diff --git a/apps/codeimage/src/state/auth/providers/hanko-passkey.provider.ts b/apps/codeimage/src/state/auth/providers/hanko-passkey.provider.ts new file mode 100644 index 000000000..37ec62192 --- /dev/null +++ b/apps/codeimage/src/state/auth/providers/hanko-passkey.provider.ts @@ -0,0 +1,115 @@ +import {createPasskeyState} from '@codeimage/store/auth/passkey/hankoPasskeyState'; +import * as webauthn from '@github/webauthn-json'; +import {cookieStorage} from '@solid-primitives/storage'; +import {jwtDecode} from 'jwt-decode'; +import { + finalizePasskeyLogin, + startPasskeyLogin, +} from '../../../data-access/passkey'; +import {getUserInfo} from '../../../data-access/user'; + +interface SessionDetail { + userID: string; + expirationSeconds: number; + jwt: string; +} + +export class HankoPasskeyAuthProvider { + private readonly state = new HankoPasskeyAuthSessionState(); + + async init() { + try { + const session = this.checkSession(); + if (!session) return; + return getUserInfo(session.jwt); + } catch (e) {} + } + + async logout(): Promise { + this.state.setJwtSession(null); + window.location.reload(); + } + + async getJwt(): Promise { + return this.state.getJwt()?.jwt ?? null; + } + + async login(): Promise { + const requestJSON = await startPasskeyLogin(); + const credential = await webauthn.get(requestJSON as any); + const response = await finalizePasskeyLogin(credential as any); + if (!response || !response.token) { + return null; + } + const {token} = response; + const jwtClaims = JSON.parse(atob(token.split('.')[1])); + const session = { + jwt: token, + expirationSeconds: jwtClaims.exp, + userID: jwtClaims.sub, + }; + this.state.setJwtSession(token); + return session; + } + + checkSession(): SessionDetail | null { + const data = this.state.getJwt(); + if (!data) { + this.state.setJwtSession(null); + return null; + } + // todo check validation + return { + jwt: data.jwt, + userID: data.decodedJwt.sub as string, + expirationSeconds: HankoPasskeyAuthProvider.timeToRemainingSeconds( + data.decodedJwt.exp, + ), + }; + } + + static timeToRemainingSeconds(time = 0) { + return time - Math.floor(Date.now() / 1000); + } + + static remainingSecondsToTime(seconds = 0) { + return Math.floor(Date.now() / 1000) + seconds; + } +} + +class HankoPasskeyAuthSessionState { + private readonly storageJwtKey = 'codeimagePasskey'; + private readonly jwtStorage = cookieStorage; + + setJwtSession(jwt: string | null): void { + if (!jwt) { + console.debug( + '[CodeImage/HankoPasskeyAuthSessionState] setSession to null', + ); + this.jwtStorage.removeItem(this.storageJwtKey); + return; + } + this.jwtStorage.setItem(this.storageJwtKey, jwt); + } + + getJwt() { + const data = this.jwtStorage.getItem(this.storageJwtKey); + if (!data) return null; + try { + const decodedJwt = jwtDecode(data, {header: false}); + if (!decodedJwt['sub'] || decodedJwt['exp'] === undefined) { + return null; + } + return { + jwt: data, + decodedJwt, + }; + } catch (e) { + console.debug( + '[CodeImage/HankoPasskeyAuthSessionState] error while decoding session from jwt', + {error: e}, + ); + return null; + } + } +} diff --git a/apps/codeimage/src/state/editor/createEditorSync.ts b/apps/codeimage/src/state/editor/createEditorSync.ts index fcf0d22ab..bbd6d17ef 100644 --- a/apps/codeimage/src/state/editor/createEditorSync.ts +++ b/apps/codeimage/src/state/editor/createEditorSync.ts @@ -1,5 +1,5 @@ import type * as ApiTypes from '@codeimage/api/api-types'; -import {getAuth0State} from '@codeimage/store/auth/auth0'; +import {AuthState} from '@codeimage/store/auth/auth'; import {getRootEditorStore} from '@codeimage/store/editor'; import {getFrameState} from '@codeimage/store/editor/frame'; import {getEditorStore} from '@codeimage/store/editor/index'; @@ -32,6 +32,7 @@ import { untrack, } from 'solid-js'; import {unwrap} from 'solid-js/store'; +import {provideState} from 'statebuilder'; import {API} from '../../data-access/api'; import {useIdb} from '../../hooks/use-indexed-db'; @@ -44,7 +45,7 @@ function createEditorSyncAdapter(props: {snippetId: string}) { const [activeWorkspace, setActiveWorkspace] = createSignal< ApiTypes.GetProjectByIdApi['response'] | null >(); - const authState = getAuth0State(); + const authState = provideState(AuthState); const frameStore = getFrameState(); const terminalStore = getTerminalState(); const editorStore = getRootEditorStore(); diff --git a/apps/codeimage/src/state/hanko.ts b/apps/codeimage/src/state/hanko.ts new file mode 100644 index 000000000..ada5bb40a --- /dev/null +++ b/apps/codeimage/src/state/hanko.ts @@ -0,0 +1,95 @@ +import * as webauthn from '@github/webauthn-json'; +import {finalizePasskeyLogin, startPasskeyLogin} from '../data-access/passkey'; + +interface SessionDetail { + userID: string; + expirationSeconds: number; + jwt: string; +} + +export class HankoAuthProvider { + static storageKey = 'passkeyState'; + + static async login(): Promise { + const requestJSON = await startPasskeyLogin(); + const credential = await webauthn.get(requestJSON as any); + const response = await finalizePasskeyLogin(credential as any); + if (!response || !response.token) { + return null; + } + + const {token} = response; + const jwtClaims = JSON.parse(atob(token.split('.')[1])); + + const sessionDetail: SessionDetail = { + jwt: token, + expirationSeconds: jwtClaims.exp, + userID: jwtClaims.sub, + }; + + localStorage.setItem(this.storageKey, JSON.stringify(sessionDetail)); + + return sessionDetail; + } + + private static validate(detail: SessionDetail): boolean { + return !!(detail.expirationSeconds > 0 && detail.userID?.length); + } + + login(): Promise { + return HankoAuthProvider.login().then(sessionDetail => { + localStorage.setItem( + HankoAuthProvider.storageKey, + JSON.stringify(sessionDetail), + ); + return sessionDetail; + }); + } + + async initLogin(): Promise { + const session = this.getSession(); + if (!session) return null; + const validate = HankoAuthProvider.validate(session); + return validate ? session : null; + } + + loggedIn(): boolean { + const session = this.getSession(); + if (!session) return false; + return HankoAuthProvider.validate(session); + } + + signOut(): void { + localStorage.removeItem(HankoAuthProvider.storageKey); + window.location.reload(); + } + + getToken(): Promise { + const session = this.getSession(); + console.log('get session', session); + return Promise.resolve(session?.jwt ?? ''); + } + + getSession(): SessionDetail | null { + const state = localStorage.getItem(HankoAuthProvider.storageKey); + if (!state) return null; + const parsedState = JSON.parse(state); + const haveKeys = ['userID', 'expirationSeconds', 'jwt'].every(key => + parsedState.hasOwnProperty(key), + ); + if (!haveKeys) { + localStorage.removeItem(HankoAuthProvider.storageKey); + return null; + } + return parsedState satisfies SessionDetail; + } + + static supported(): boolean { + return !!( + !!navigator.credentials && + !!navigator.credentials.create && + !!navigator.credentials.get && + window.PublicKeyCredential + ); + } +} diff --git a/apps/codeimage/src/state/presets/bridge.ts b/apps/codeimage/src/state/presets/bridge.ts index a264e5df2..1d9ff1ac9 100644 --- a/apps/codeimage/src/state/presets/bridge.ts +++ b/apps/codeimage/src/state/presets/bridge.ts @@ -1,8 +1,9 @@ import * as ApiTypes from '@codeimage/api/api-types'; -import {getAuth0State} from '@codeimage/store/auth/auth0'; +import {AuthState} from '@codeimage/store/auth/auth'; import {getRootEditorStore} from '@codeimage/store/editor'; import {getFrameState} from '@codeimage/store/editor/frame'; import {getTerminalState} from '@codeimage/store/editor/terminal'; +import {provideAppState} from '@codeimage/store/index'; import {generateUid} from '@codeimage/store/plugins/unique-id'; import {appEnvironment} from '@core/configuration'; import {createEffect, on} from 'solid-js'; @@ -19,7 +20,8 @@ export const withPresetBridge = (idbKey: string) => makePlugin( store => { const idb = useIdb(); - const useInMemoryStore = () => !getAuth0State().loggedIn(); + const authState = provideAppState(AuthState); + const useInMemoryStore = () => !authState.loggedIn(); function persistToIdb(data: PresetsArray) { return idb.set(idbKey, unwrap(data)).then(); } diff --git a/apps/codeimage/src/state/presets/presets.ts b/apps/codeimage/src/state/presets/presets.ts index 0f21719e9..f5609c4c5 100644 --- a/apps/codeimage/src/state/presets/presets.ts +++ b/apps/codeimage/src/state/presets/presets.ts @@ -1,3 +1,4 @@ +import {AuthState} from '@codeimage/store/auth/auth'; import {withEntityPlugin} from '@codeimage/store/plugins/withEntityPlugin'; import {withIndexedDbPlugin} from '@codeimage/store/plugins/withIndexedDbPlugin'; import {toast} from '@codeimage/ui'; @@ -6,7 +7,6 @@ import {withAsyncAction} from 'statebuilder/asyncAction'; import {provideAppState} from '..'; import * as api from '../../data-access/preset'; import {useIdb} from '../../hooks/use-indexed-db'; -import {getAuth0State} from '../auth/auth0'; import {experimental__defineResource} from '../plugins/bindStateBuilderResource'; import {withPresetBridge} from './bridge'; import {Preset, PresetsArray} from './types'; @@ -28,7 +28,8 @@ function mergeDbPresetsWithLocalPresets( } async function fetchInitialState() { - const useInMemoryStore = !getAuth0State().loggedIn(); + const authState = provideAppState(AuthState); + const useInMemoryStore = !authState.loggedIn(); const localPresets = await idb .get(idbKey) .then(data => data ?? ([] as PresetsArray)) diff --git a/apps/codeimage/src/ui/GithubLoginButton/GithubLoginButton.tsx b/apps/codeimage/src/ui/GithubLoginButton/GithubLoginButton.tsx index 23c046526..a3b919c08 100644 --- a/apps/codeimage/src/ui/GithubLoginButton/GithubLoginButton.tsx +++ b/apps/codeimage/src/ui/GithubLoginButton/GithubLoginButton.tsx @@ -1,4 +1,5 @@ -import {getAuth0State} from '@codeimage/store/auth/auth0'; +import {AuthState} from '@codeimage/store/auth/auth'; +import {provideAppState} from '@codeimage/store/index'; import {Box, SvgIcon, SvgIconProps} from '@codeimage/ui'; import {Button} from '@codeui/kit'; import {useModality} from '@core/hooks/isMobile'; @@ -14,10 +15,14 @@ function GithubIcon(props: SvgIconProps) { } export function GithubLoginButton() { - const {login} = getAuth0State(); + const appState = provideAppState(AuthState); const modality = useModality(); return ( - - - - - ); -} diff --git a/apps/codeimage/src/components/Toolbar/LoginDialog/LoginDialog.css.ts b/apps/codeimage/src/components/Toolbar/LoginDialog/LoginDialog.css.ts new file mode 100644 index 000000000..3a13d54ad --- /dev/null +++ b/apps/codeimage/src/components/Toolbar/LoginDialog/LoginDialog.css.ts @@ -0,0 +1,40 @@ +import {themeTokens, themeVars} from '@codeui/kit'; +import {globalStyle, keyframes, style} from '@vanilla-extract/css'; + +export const titleLogin = style({ + fontSize: '2rem', + textAlign: 'center', + fontWeight: themeTokens.fontWeight.bold, + display: 'flex', + flexDirection: 'column', + gap: themeTokens.spacing['4'], + alignItems: 'center', + marginTop: themeTokens.spacing['8'], +}); + +export const loginBox = style({ + display: 'flex', + flexDirection: 'column', + gap: themeTokens.spacing['2'], + marginTop: themeTokens.spacing['12'], + marginBottom: themeTokens.spacing['12'], +}); + +export const closeIcon = style({ + width: '100%', + display: 'flex', + justifyContent: 'flex-end', +}); + +const backdropFilter = keyframes({ + '0%': { + backdropFilter: 'blur(0px) saturate(180%)', + }, + '100%': { + backdropFilter: 'blur(20px) saturate(180%)', + }, +}); + +globalStyle('div[data-panel-size]:has(div[id=loginDialog-content])', { + animation: `${backdropFilter} 150ms normal forwards ease-in-out`, +}); diff --git a/apps/codeimage/src/components/Toolbar/LoginDialog/LoginDialog.tsx b/apps/codeimage/src/components/Toolbar/LoginDialog/LoginDialog.tsx new file mode 100644 index 000000000..4bbca1d8b --- /dev/null +++ b/apps/codeimage/src/components/Toolbar/LoginDialog/LoginDialog.tsx @@ -0,0 +1,67 @@ +import {AuthState} from '@codeimage/store/auth/auth'; +import {provideAppState} from '@codeimage/store/index'; +import {Box, VStack} from '@codeimage/ui'; +import { + Button, + Dialog, + DialogPanelContent, + IconButton, + SvgIcon, +} from '@codeui/kit'; +import {ControlledDialogProps} from '@core/hooks/createControlledDialog'; +import {GithubLoginButton} from '@ui/GithubLoginButton/GithubLoginButton'; +import {CloseIcon} from '../../Icons/CloseIcon'; +import {CodeImageLogoV2} from '../../Icons/CodeImageLogoV2'; +import {closeIcon, loginBox, titleLogin} from './LoginDialog.css'; + +export function LoginDialog(props: ControlledDialogProps) { + const authState = provideAppState(AuthState); + return ( +

+ +
+ + + +
+
+ +
+ +
+ + + +
+
+
+ ); +} diff --git a/apps/codeimage/src/components/UserBadge/UserBadge.tsx b/apps/codeimage/src/components/UserBadge/UserBadge.tsx index 86e839ac6..9073fac3b 100644 --- a/apps/codeimage/src/components/UserBadge/UserBadge.tsx +++ b/apps/codeimage/src/components/UserBadge/UserBadge.tsx @@ -10,9 +10,7 @@ import { DropdownMenuPortal, DropdownMenuTrigger, } from '@codeui/kit'; -import {createControlledDialog} from '@core/hooks/createControlledDialog'; import {Show} from 'solid-js'; -import {LoginDialog} from '../Toolbar/LoginDialog'; import * as styles from './UserBadge.css'; export function UserBadge() { @@ -20,8 +18,6 @@ export function UserBadge() { const user = () => authState().user; const profileImage = () => user()?.picture; - const openDialog = createControlledDialog(); - const initials = () => { const userValue = user(); if (!userValue) return; @@ -35,7 +31,7 @@ export function UserBadge() { return ( openDialog(LoginDialog, {})}> + } diff --git a/apps/codeimage/src/state/auth/auth.ts b/apps/codeimage/src/state/auth/auth.ts index e0def98ce..26bb60109 100644 --- a/apps/codeimage/src/state/auth/auth.ts +++ b/apps/codeimage/src/state/auth/auth.ts @@ -1,9 +1,9 @@ import {Auth0Provider} from '@codeimage/store/auth/providers/auth0.provider'; import {HankoPasskeyAuthProvider} from '@codeimage/store/auth/providers/hanko-passkey.provider'; -import {auth0, createAuth0Client} from '@core/constants/auth0'; +import {createAuth0Client} from '@core/constants/auth0'; import {createControlledDialog} from '@core/hooks/createControlledDialog'; import {defineSignal} from 'statebuilder'; -import {LoginDialog} from '../../components/Toolbar/LoginDialog'; +import {LoginDialog} from '../../components/Toolbar/LoginDialog/LoginDialog'; import {UserInfoResponse} from '../../data-access/user'; export interface AuthState { @@ -63,6 +63,8 @@ export const AuthState = defineSignal(() => ({ } }; + const openDialog = createControlledDialog(); + return { init, getToken: () => currentProvider().getJwt(), @@ -72,7 +74,6 @@ export const AuthState = defineSignal(() => ({ localStorage.removeItem('auth_strategy'); }, openLoginPopup() { - const openDialog = createControlledDialog(); return openDialog(LoginDialog, {}); }, providers: { diff --git a/apps/codeimage/src/ui/GithubLoginButton/GithubLoginButton.tsx b/apps/codeimage/src/ui/GithubLoginButton/GithubLoginButton.tsx index a3b919c08..3e3b3f835 100644 --- a/apps/codeimage/src/ui/GithubLoginButton/GithubLoginButton.tsx +++ b/apps/codeimage/src/ui/GithubLoginButton/GithubLoginButton.tsx @@ -19,16 +19,14 @@ export function GithubLoginButton() { const modality = useModality(); return ( ); } From ff17bb904b88eef790c8f54b77390060aae3ef58 Mon Sep 17 00:00:00 2001 From: riccardoperra Date: Wed, 20 Dec 2023 23:04:55 +0100 Subject: [PATCH 06/16] feat: login/profile dialog --- apps/api/api-types/index.d.ts | 1 + apps/api/src/plugins/swagger.ts | 1 - .../src/routes/v1/passkey/deleteCredential.ts | 16 +- .../src/routes/v1/passkey/finalizeLogin.ts | 30 ++-- apps/api/src/schemas/index.ts | 1 + apps/api/tsconfig.json | 5 +- .../src/components/Icons/TrashIcon.tsx | 23 +++ .../Toolbar/LoginDialog/LoginDialog.css.ts | 7 +- .../Toolbar/LoginDialog/LoginDialog.tsx | 21 +-- .../ProfileDialog/ProfileDialog.css.ts | 47 ++++++ .../Toolbar/ProfileDialog/ProfileDialog.tsx | 137 ++++++++++++++++++ .../src/components/Toolbar/Toolbar.tsx | 3 +- .../src/components/UserBadge/KeyIcon.tsx | 22 +++ .../src/components/UserBadge/ProfileBadge.tsx | 52 +++++++ .../src/components/UserBadge/UserBadge.tsx | 44 +----- apps/codeimage/src/data-access/client.ts | 4 +- apps/codeimage/src/data-access/passkey.ts | 42 +++++- .../DashboardHeader/DashboardHeader.tsx | 3 +- apps/codeimage/src/state/auth/auth.ts | 3 + .../auth/providers/hanko-passkey.provider.ts | 31 +++- .../GithubLoginButton.css.ts | 2 +- .../GithubLoginButton/GithubLoginButton.tsx | 2 +- 22 files changed, 406 insertions(+), 91 deletions(-) create mode 100644 apps/codeimage/src/components/Icons/TrashIcon.tsx create mode 100644 apps/codeimage/src/components/Toolbar/ProfileDialog/ProfileDialog.css.ts create mode 100644 apps/codeimage/src/components/Toolbar/ProfileDialog/ProfileDialog.tsx create mode 100644 apps/codeimage/src/components/UserBadge/KeyIcon.tsx create mode 100644 apps/codeimage/src/components/UserBadge/ProfileBadge.tsx diff --git a/apps/api/api-types/index.d.ts b/apps/api/api-types/index.d.ts index f280b72ba..a74111606 100644 --- a/apps/api/api-types/index.d.ts +++ b/apps/api/api-types/index.d.ts @@ -15,4 +15,5 @@ export type { PasskeyStartLoginApi, PasskeyFinalizeLoginApi, UserInfoApi, + DeleteCredentialApi, } from '../src/schemas/index'; diff --git a/apps/api/src/plugins/swagger.ts b/apps/api/src/plugins/swagger.ts index 634bd5115..4fd5ebfbe 100644 --- a/apps/api/src/plugins/swagger.ts +++ b/apps/api/src/plugins/swagger.ts @@ -1,7 +1,6 @@ import fastifySwagger from '@fastify/swagger'; import fastifySwaggerUi from '@fastify/swagger-ui'; import fp from 'fastify-plugin'; -// @ts-expect-error IntelliJ may not support that import packageJson from '../../package.json' assert {type: 'json'}; export default fp(async fastify => { diff --git a/apps/api/src/routes/v1/passkey/deleteCredential.ts b/apps/api/src/routes/v1/passkey/deleteCredential.ts index ad5301c3c..dc15f7a51 100644 --- a/apps/api/src/routes/v1/passkey/deleteCredential.ts +++ b/apps/api/src/routes/v1/passkey/deleteCredential.ts @@ -1,5 +1,15 @@ import {FastifyPluginAsyncTypebox} from '@fastify/type-provider-typebox'; import {Type} from '@sinclair/typebox'; +import {FastifySchema} from 'fastify'; +import {GetApiTypes} from '../../../common/types/extract-api-types.js'; + +const schema = { + params: Type.Object({ + credentialId: Type.String(), + }), +} satisfies FastifySchema; + +export type DeleteCredentialApi = GetApiTypes; const route: FastifyPluginAsyncTypebox = async fastify => { fastify.addHook( @@ -7,7 +17,7 @@ const route: FastifyPluginAsyncTypebox = async fastify => { fastify.authorize({mustBeAuthenticated: true}), ); fastify.delete( - '/credentials', + '/credentials/:credentialId', { schema: { params: Type.Object({ @@ -16,7 +26,9 @@ const route: FastifyPluginAsyncTypebox = async fastify => { }, }, async request => { - return fastify.passkeysApi.credential(request.params.credentialId); + return fastify.passkeysApi + .credential(request.params.credentialId) + .remove(); }, ); }; diff --git a/apps/api/src/routes/v1/passkey/finalizeLogin.ts b/apps/api/src/routes/v1/passkey/finalizeLogin.ts index 0fee7d4c0..6cf277f9d 100644 --- a/apps/api/src/routes/v1/passkey/finalizeLogin.ts +++ b/apps/api/src/routes/v1/passkey/finalizeLogin.ts @@ -51,19 +51,23 @@ const route: FastifyPluginAsyncTypebox = async fastify => { fastify.log.info( `Finalize passkey login for user with id ${request.body.id}`, ); - return fastify.passkeysApi.login.finalize({ - id: request.body.id, - type: request.body.type, - clientExtensionResults: request.body.clientExtensionResults, - authenticatorAttachment: request.body.authenticatorAttachment, - rawId: request.body.rawId, - response: { - clientDataJSON: request.body.response.clientDataJSON, - signature: request.body.response.signature, - userHandle: request.body.response.userHandle, - authenticatorData: request.body.response.authenticatorData, - }, - }); + try { + return await fastify.passkeysApi.login.finalize({ + id: request.body.id, + type: request.body.type, + clientExtensionResults: request.body.clientExtensionResults, + authenticatorAttachment: request.body.authenticatorAttachment, + rawId: request.body.rawId, + response: { + clientDataJSON: request.body.response.clientDataJSON, + signature: request.body.response.signature, + userHandle: request.body.response.userHandle, + authenticatorData: request.body.response.authenticatorData, + }, + }); + } catch (e) { + throw fastify.httpErrors.unauthorized(e.originalError.details); + } }); }; diff --git a/apps/api/src/schemas/index.ts b/apps/api/src/schemas/index.ts index 0992d909d..35f4fcc77 100644 --- a/apps/api/src/schemas/index.ts +++ b/apps/api/src/schemas/index.ts @@ -15,4 +15,5 @@ export type {PasskeyStartRegistrationApi} from '../routes/v1/passkey/startRegist export type {PasskeyFinalizeRegistrationApi} from '../routes/v1/passkey/finalizeRegistration.js'; export type {PasskeyStartLoginApi} from '../routes/v1/passkey/startLogin.js'; export type {PasskeyFinalizeLoginApi} from '../routes/v1/passkey/finalizeLogin.js'; +export type {DeleteCredentialApi} from '../routes/v1/passkey/deleteCredential.js'; export type {UserInfoApi} from '../routes/v1/user/info.js'; diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index ac335409f..f41f4c027 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -2,7 +2,8 @@ "extends": "fastify-tsconfig", "compilerOptions": { "outDir": "dist", - "resolveJsonModule": false, + "resolveJsonModule": true, + "declaration": true, "allowSyntheticDefaultImports": true, "sourceMap": true, "moduleResolution": "NodeNext", @@ -15,4 +16,4 @@ "include": [ "./src/**/*.ts" ] -} \ No newline at end of file +} diff --git a/apps/codeimage/src/components/Icons/TrashIcon.tsx b/apps/codeimage/src/components/Icons/TrashIcon.tsx new file mode 100644 index 000000000..1e4eea1f9 --- /dev/null +++ b/apps/codeimage/src/components/Icons/TrashIcon.tsx @@ -0,0 +1,23 @@ +import {SvgIcon, SvgIconProps} from '@codeimage/ui'; + +export function TrashIcon(props: SvgIconProps) { + return ( + + + + + + + + ); +} diff --git a/apps/codeimage/src/components/Toolbar/LoginDialog/LoginDialog.css.ts b/apps/codeimage/src/components/Toolbar/LoginDialog/LoginDialog.css.ts index 3a13d54ad..8fab097f3 100644 --- a/apps/codeimage/src/components/Toolbar/LoginDialog/LoginDialog.css.ts +++ b/apps/codeimage/src/components/Toolbar/LoginDialog/LoginDialog.css.ts @@ -9,7 +9,7 @@ export const titleLogin = style({ flexDirection: 'column', gap: themeTokens.spacing['4'], alignItems: 'center', - marginTop: themeTokens.spacing['8'], + marginTop: themeTokens.spacing['6'], }); export const loginBox = style({ @@ -17,7 +17,6 @@ export const loginBox = style({ flexDirection: 'column', gap: themeTokens.spacing['2'], marginTop: themeTokens.spacing['12'], - marginBottom: themeTokens.spacing['12'], }); export const closeIcon = style({ @@ -38,3 +37,7 @@ const backdropFilter = keyframes({ globalStyle('div[data-panel-size]:has(div[id=loginDialog-content])', { animation: `${backdropFilter} 150ms normal forwards ease-in-out`, }); + +globalStyle('[data-cui-theme=dark] div[id=loginDialog-content]', { + background: themeVars.accent1, +}); diff --git a/apps/codeimage/src/components/Toolbar/LoginDialog/LoginDialog.tsx b/apps/codeimage/src/components/Toolbar/LoginDialog/LoginDialog.tsx index 4bbca1d8b..818ac7a2f 100644 --- a/apps/codeimage/src/components/Toolbar/LoginDialog/LoginDialog.tsx +++ b/apps/codeimage/src/components/Toolbar/LoginDialog/LoginDialog.tsx @@ -12,6 +12,7 @@ import {ControlledDialogProps} from '@core/hooks/createControlledDialog'; import {GithubLoginButton} from '@ui/GithubLoginButton/GithubLoginButton'; import {CloseIcon} from '../../Icons/CloseIcon'; import {CodeImageLogoV2} from '../../Icons/CodeImageLogoV2'; +import {KeyIcon} from '../../UserBadge/KeyIcon'; import {closeIcon, loginBox, titleLogin} from './LoginDialog.css'; export function LoginDialog(props: ControlledDialogProps) { @@ -37,25 +38,9 @@ export function LoginDialog(props: ControlledDialogProps) { + + + }> +
    + + {passkey => ( +
  • + {passkey.id} + + removePasskey(passkey.id)} + > + + + + + + + + + + + + + Do you want to delete this passkey from your + account? + +
    + +
    +
    +
    +
    +
    +
  • + )} +
    +
+
+ + + + + + + ); +} diff --git a/apps/codeimage/src/components/Toolbar/Toolbar.tsx b/apps/codeimage/src/components/Toolbar/Toolbar.tsx index 8c9872e4a..18bc1944a 100644 --- a/apps/codeimage/src/components/Toolbar/Toolbar.tsx +++ b/apps/codeimage/src/components/Toolbar/Toolbar.tsx @@ -12,6 +12,7 @@ import {createMemo, Show, VoidProps} from 'solid-js'; import {CodeImageLogoV2} from '../Icons/CodeImageLogoV2'; import {CollectionIcon} from '../Icons/Collection'; import {sidebarLogo} from '../Scaffold/Sidebar/Sidebar.css'; +import {ProfileBadge} from '../UserBadge/ProfileBadge'; import {UserBadge} from '../UserBadge/UserBadge'; import {ExportButton} from './ExportButton'; import {ShareButton} from './ShareButton'; @@ -84,7 +85,7 @@ export function Toolbar(props: VoidProps) {
- + diff --git a/apps/codeimage/src/components/UserBadge/KeyIcon.tsx b/apps/codeimage/src/components/UserBadge/KeyIcon.tsx new file mode 100644 index 000000000..1c1e56497 --- /dev/null +++ b/apps/codeimage/src/components/UserBadge/KeyIcon.tsx @@ -0,0 +1,22 @@ +import {SvgIcon, SvgIconProps} from '@codeimage/ui'; + +export function KeyIcon(props: SvgIconProps) { + return ( + + + + + + ); +} diff --git a/apps/codeimage/src/components/UserBadge/ProfileBadge.tsx b/apps/codeimage/src/components/UserBadge/ProfileBadge.tsx new file mode 100644 index 000000000..f885ae05f --- /dev/null +++ b/apps/codeimage/src/components/UserBadge/ProfileBadge.tsx @@ -0,0 +1,52 @@ +import {AuthState} from '@codeimage/store/auth/auth'; +import {provideAppState} from '@codeimage/store/index'; +import {Badge} from '@codeimage/ui'; +import { + As, + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuPortal, + DropdownMenuTrigger, +} from '@codeui/kit'; +import {createControlledDialog} from '@core/hooks/createControlledDialog'; +import {Show} from 'solid-js'; +import {ProfileDialog} from '../Toolbar/ProfileDialog/ProfileDialog'; +import {UserBadge} from './UserBadge'; + +export function ProfileBadge() { + const authState = provideAppState(AuthState); + const openDialog = createControlledDialog(); + const openProfilePopup = () => { + openDialog(ProfileDialog, {}); + }; + + return ( + authState.openLoginPopup()}> + Login + + } + when={authState.loggedIn()} + > + + + + + + + + openProfilePopup()}> + Profile + + authState.signOut()}> + Logout + + + + + + ); +} diff --git a/apps/codeimage/src/components/UserBadge/UserBadge.tsx b/apps/codeimage/src/components/UserBadge/UserBadge.tsx index 9073fac3b..293b0a545 100644 --- a/apps/codeimage/src/components/UserBadge/UserBadge.tsx +++ b/apps/codeimage/src/components/UserBadge/UserBadge.tsx @@ -1,19 +1,10 @@ import {AuthState} from '@codeimage/store/auth/auth'; import {provideAppState} from '@codeimage/store/index'; import {Badge} from '@codeimage/ui'; -import { - As, - Button, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuPortal, - DropdownMenuTrigger, -} from '@codeui/kit'; import {Show} from 'solid-js'; import * as styles from './UserBadge.css'; -export function UserBadge() { +export function UserBadge(props) { const authState = provideAppState(AuthState); const user = () => authState().user; const profileImage = () => user()?.picture; @@ -29,32 +20,11 @@ export function UserBadge() { }; return ( - authState.openLoginPopup()}> - Login - - } - when={authState.loggedIn()} - > - - - - {initials()} - - - - - - - - - authState.signOut()}> - Logout - - - - - + + {initials()} + + + + ); } diff --git a/apps/codeimage/src/data-access/client.ts b/apps/codeimage/src/data-access/client.ts index 2ede1dde6..d0edd61e9 100644 --- a/apps/codeimage/src/data-access/client.ts +++ b/apps/codeimage/src/data-access/client.ts @@ -17,6 +17,7 @@ export interface Schema { export async function makeFetch( input: RequestInfo, requestParams: Omit & RequestParams, + withAuthentication = true, ): Promise { const {forceLogin} = getAuth0State(); const {getToken, loggedIn} = provideAppState(AuthState); @@ -24,8 +25,7 @@ export async function makeFetch( let url = typeof input === 'string' ? input : input.url; const headers = new Headers(); const request: RequestInit = {...(requestParams as RequestInit)}; - - if (loggedIn()) { + if (withAuthentication && loggedIn()) { try { const token = await getToken(); if (token) { diff --git a/apps/codeimage/src/data-access/passkey.ts b/apps/codeimage/src/data-access/passkey.ts index 5821aeb6f..b50ca1500 100644 --- a/apps/codeimage/src/data-access/passkey.ts +++ b/apps/codeimage/src/data-access/passkey.ts @@ -24,16 +24,46 @@ export async function finalizePasskeyRegistration( export async function startPasskeyLogin(): Promise< ApiTypes.PasskeyStartLoginApi['response'] > { - return makeFetch(`${BASE_URL}/api/v1/passkey/start-login`, { - method: 'POST', - }).then(res => res.json()); + return makeFetch( + `${BASE_URL}/api/v1/passkey/start-login`, + { + method: 'POST', + }, + false, + ).then(res => res.json()); } export async function finalizePasskeyLogin( body: ApiTypes.PasskeyFinalizeLoginApi['request']['body'], ): Promise { - return makeFetch(`${BASE_URL}/api/v1/passkey/finalize-login`, { - method: 'POST', - body, + return makeFetch( + `${BASE_URL}/api/v1/passkey/finalize-login`, + { + method: 'POST', + body, + }, + false, + ).then(res => res.json()); +} + +export async function listPasskeys(): Promise< + ApiTypes.PasskeyStartLoginApi['response'][] +> { + return makeFetch(`${BASE_URL}/api/v1/passkey/credentials`, { + method: 'GET', }).then(res => res.json()); } + +export async function deletePasskey( + data: ApiTypes.DeleteCredentialApi['request'], +): Promise { + return makeFetch( + `${BASE_URL}/api/v1/passkey/credentials/:id`.replace( + ':id', + data.params!.credentialId, + ), + { + method: 'DELETE', + }, + ).then(res => res.json()); +} diff --git a/apps/codeimage/src/pages/Dashboard/components/DashboardHeader/DashboardHeader.tsx b/apps/codeimage/src/pages/Dashboard/components/DashboardHeader/DashboardHeader.tsx index d478ee76f..1a969e8cb 100644 --- a/apps/codeimage/src/pages/Dashboard/components/DashboardHeader/DashboardHeader.tsx +++ b/apps/codeimage/src/pages/Dashboard/components/DashboardHeader/DashboardHeader.tsx @@ -3,6 +3,7 @@ import {CodeImageLogoV2} from '../../../../components/Icons/CodeImageLogoV2'; import {sidebarLogo} from '../../../../components/Scaffold/Sidebar/Sidebar.css'; import {actionBox} from '../../../../components/Toolbar/Toolbar.css'; import {ToolbarSettingsButton} from '../../../../components/Toolbar/ToolbarSettings'; +import {ProfileBadge} from '../../../../components/UserBadge/ProfileBadge'; import {UserBadge} from '../../../../components/UserBadge/UserBadge'; import * as styles from './DashboardHeader.css'; @@ -20,7 +21,7 @@ export function DashboardHeader() { - + diff --git a/apps/codeimage/src/state/auth/auth.ts b/apps/codeimage/src/state/auth/auth.ts index 26bb60109..1589c450e 100644 --- a/apps/codeimage/src/state/auth/auth.ts +++ b/apps/codeimage/src/state/auth/auth.ts @@ -92,6 +92,9 @@ export const AuthState = defineSignal(() => ({ } window.location.reload(); }, + registerPasskey: async () => { + await providers.hankoPasskey.registerPasskey(); + }, }, }, }; diff --git a/apps/codeimage/src/state/auth/providers/hanko-passkey.provider.ts b/apps/codeimage/src/state/auth/providers/hanko-passkey.provider.ts index 37ec62192..ccd57b7a6 100644 --- a/apps/codeimage/src/state/auth/providers/hanko-passkey.provider.ts +++ b/apps/codeimage/src/state/auth/providers/hanko-passkey.provider.ts @@ -1,10 +1,12 @@ -import {createPasskeyState} from '@codeimage/store/auth/passkey/hankoPasskeyState'; import * as webauthn from '@github/webauthn-json'; import {cookieStorage} from '@solid-primitives/storage'; import {jwtDecode} from 'jwt-decode'; +import {of} from 'rxjs'; import { finalizePasskeyLogin, + finalizePasskeyRegistration, startPasskeyLogin, + startPasskeyRegistration, } from '../../../data-access/passkey'; import {getUserInfo} from '../../../data-access/user'; @@ -42,14 +44,35 @@ export class HankoPasskeyAuthProvider { return null; } const {token} = response; + const session = this.getSessionFromToken(token); + this.state.setJwtSession(token); + return session; + } + + async getSessionFromToken(token: string) { const jwtClaims = JSON.parse(atob(token.split('.')[1])); - const session = { + return { jwt: token, expirationSeconds: jwtClaims.exp, userID: jwtClaims.sub, }; - this.state.setJwtSession(token); - return session; + } + + async registerPasskey(): Promise<{token?: string | undefined}> { + try { + const credentials = await startPasskeyRegistration(); + const attestation = await webauthn.create(credentials as any); + const response = await finalizePasskeyRegistration(attestation); + if (!response || !response.token) { + this.state.setJwtSession(null); + return response; + } + this.state.setJwtSession(response.token); + return response; + } catch (e) { + console.log(e); + throw e; + } } checkSession(): SessionDetail | null { diff --git a/apps/codeimage/src/ui/GithubLoginButton/GithubLoginButton.css.ts b/apps/codeimage/src/ui/GithubLoginButton/GithubLoginButton.css.ts index 25307dba6..a13cb3219 100644 --- a/apps/codeimage/src/ui/GithubLoginButton/GithubLoginButton.css.ts +++ b/apps/codeimage/src/ui/GithubLoginButton/GithubLoginButton.css.ts @@ -7,6 +7,6 @@ export const button = style({ cursor: 'pointer', ':hover': { - background: 'rgb(29,33,35)', + background: 'rgb(29,33,35) !important', }, }); diff --git a/apps/codeimage/src/ui/GithubLoginButton/GithubLoginButton.tsx b/apps/codeimage/src/ui/GithubLoginButton/GithubLoginButton.tsx index 3e3b3f835..25f3bfa78 100644 --- a/apps/codeimage/src/ui/GithubLoginButton/GithubLoginButton.tsx +++ b/apps/codeimage/src/ui/GithubLoginButton/GithubLoginButton.tsx @@ -19,7 +19,7 @@ export function GithubLoginButton() { const modality = useModality(); return ( + + + + + + } + when={editing()} + > + { + setEditing(false); + setCurrentValue(passkey.name); + }} > - - - - - - - Do you want to delete this passkey from your - account? - -
- -
-
-
- - - - )} + +
+ + { + setEditing(false); + confirmEditPasskey(passkey.id, currentValue()); + }} + > + + + + + + ); + }} diff --git a/apps/codeimage/src/data-access/passkey.ts b/apps/codeimage/src/data-access/passkey.ts index 0b5c703fd..f91b2d3f5 100644 --- a/apps/codeimage/src/data-access/passkey.ts +++ b/apps/codeimage/src/data-access/passkey.ts @@ -47,7 +47,7 @@ export async function finalizePasskeyLogin( } export async function listPasskeys(): Promise< - ApiTypes.PasskeyListCredentialsApi['response'][] + ApiTypes.PasskeyListCredentialsApi['response'] > { return makeFetch(`${BASE_URL}/api/v1/passkey/credentials`, { method: 'GET', @@ -67,3 +67,18 @@ export async function deletePasskey( }, ).then(res => res.json()); } + +export async function editPasskey( + data: ApiTypes.PasskeyUpdateCredentialsApi['request'], +): Promise { + return makeFetch( + `${BASE_URL}/api/v1/passkey/credentials/:id`.replace( + ':id', + data.params!.credentialId, + ), + { + method: 'PATCH', + body: data.body, + }, + ).then(res => res.json()); +}