Skip to content

Commit d3da1cf

Browse files
committed
feat: add hanko api passkey integration
1 parent c91d8ec commit d3da1cf

29 files changed

+629
-194
lines changed

apps/api/api-types/index.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,8 @@ export type {
1010
GetPresetByIdApi,
1111
UpdatePresetApi,
1212
GetAllPresetApi,
13+
PasskeyStartRegistrationApi,
14+
PasskeyFinalizeRegistrationApi,
15+
PasskeyStartLoginApi,
16+
PasskeyFinalizeLoginApi,
1317
} from '../dist/schemas/index.js';

apps/api/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"license": "ISC",
4141
"dependencies": {
4242
"@codeimage/prisma-models": "workspace:*",
43+
"@fastify/auth": "4.4.0",
4344
"@fastify/autoload": "^5.7.1",
4445
"@fastify/cors": "^8.3.0",
4546
"@fastify/env": "^4.2.0",
@@ -50,13 +51,16 @@
5051
"@fastify/type-provider-typebox": "^3.2.0",
5152
"@prisma/client": "^4.15.0",
5253
"@sinclair/typebox": "^0.28.15",
54+
"@teamhanko/passkeys-sdk": "0.1.8",
55+
"auth0": "4.2.0",
5356
"close-with-grace": "^1.2.0",
5457
"dotenv": "^16.1.4",
5558
"dotenv-cli": "^6.0.0",
5659
"fastify": "^4.18.0",
5760
"fastify-auth0-verify": "^1.2.0",
5861
"fastify-cli": "^5.7.1",
5962
"fastify-healthcheck": "^4.4.0",
63+
"fastify-jwt-jwks": "1.1.4",
6064
"fastify-plugin": "^4.5.0",
6165
"fluent-json-schema": "^4.1.0",
6266
"prisma": "^4.15.0"

apps/api/src/app.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import AutoLoad, {AutoloadPluginOptions} from '@fastify/autoload';
22
import fastifyEnv from '@fastify/env';
33
import {Type} from '@sinclair/typebox';
44
import {FastifyPluginAsync} from 'fastify';
5+
import fp from 'fastify-plugin';
56
import path, {join} from 'node:path';
67
import {fileURLToPath} from 'node:url';
78

@@ -20,6 +21,9 @@ declare module 'fastify' {
2021
GRANT_TYPE_AUTH0?: string;
2122
ALLOWED_ORIGINS?: string;
2223
PRESETS_LIMIT?: number;
24+
HANKO_PASSKEYS_LOGIN_BASE_URL: string;
25+
HANKO_PASSKEYS_TENANT_ID: string;
26+
HANKO_PASSKEYS_API_KEY: string;
2327
};
2428
}
2529
}
@@ -51,6 +55,9 @@ const app: FastifyPluginAsync<AppOptions> = async (
5155
GRANT_TYPE_AUTH0: Type.String(),
5256
ALLOWED_ORIGINS: Type.String(),
5357
PRESETS_LIMIT: Type.Number({default: Number.MAX_SAFE_INTEGER}),
58+
HANKO_PASSKEYS_LOGIN_BASE_URL: Type.String(),
59+
HANKO_PASSKEYS_TENANT_ID: Type.String(),
60+
HANKO_PASSKEYS_API_KEY: Type.String(),
5461
}),
5562
});
5663

@@ -61,6 +68,7 @@ const app: FastifyPluginAsync<AppOptions> = async (
6168
dir: join(__dirname, 'plugins'),
6269
options: opts,
6370
forceESM: true,
71+
encapsulate: false,
6472
});
6573

6674
// This loads all plugins defined in routes
Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,32 @@
1-
import {SchemaOptions, TSchema, Type} from '@sinclair/typebox';
1+
import {
2+
SchemaOptions,
3+
TNull,
4+
TOptional,
5+
TSchema,
6+
TUnion,
7+
Type,
8+
} from '@sinclair/typebox';
29

3-
export const Nullable = <T extends TSchema>(tType: T, optional = true) => {
10+
export function Nullable<T extends TSchema>(
11+
tType: T,
12+
optional?: true,
13+
): TOptional<TUnion<[T, TNull]>>;
14+
export function Nullable<T extends TSchema>(
15+
tType: T,
16+
optional?: false,
17+
): TUnion<[T, TNull]>;
18+
export function Nullable<T extends TSchema>(
19+
tType: T,
20+
optional?: boolean,
21+
): TOptional<TUnion<[T, TNull]>> | TUnion<[T, TNull]> {
422
const options: SchemaOptions | undefined = Reflect.has(tType, 'default')
523
? {default: tType.default}
624
: undefined;
725

826
const resolvedType = Type.Union([tType, Type.Null()], options);
927

10-
if (optional) {
28+
if (optional === undefined || optional) {
1129
return Type.Optional(resolvedType);
1230
}
1331
return resolvedType;
14-
};
32+
}

apps/api/src/plugins/auth0.ts

Lines changed: 35 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@ import '@fastify/jwt';
33
import {
44
FastifyInstance,
55
FastifyPluginAsync,
6-
FastifyReply,
7-
FastifyRequest,
6+
preHandlerHookHandler,
87
} from 'fastify';
98
import fastifyAuth0Verify, {Authenticate} from 'fastify-auth0-verify';
109
import fp from 'fastify-plugin';
@@ -54,67 +53,57 @@ export default fp<{authProvider?: FastifyPluginAsync}>(
5453
});
5554
}
5655

57-
async function authorize(
58-
req: FastifyRequest,
59-
reply: FastifyReply,
60-
options: AuthorizeOptions = {
61-
mustBeAuthenticated: true,
62-
},
63-
) {
64-
try {
65-
await fastify.authenticate(req, reply);
66-
} catch (e) {
67-
if (options.mustBeAuthenticated) {
68-
throw fastify.httpErrors.unauthorized();
56+
const authorize: (options: AuthorizeOptions) => preHandlerHookHandler =
57+
(options = {mustBeAuthenticated: true}) =>
58+
async (req, reply) => {
59+
try {
60+
await fastify.authenticate(req, reply);
61+
} catch (e) {
62+
if (options.mustBeAuthenticated) {
63+
throw fastify.httpErrors.unauthorized();
64+
}
6965
}
70-
}
7166

72-
const emailClaim = `${fastify.config.AUTH0_CLIENT_CLAIMS}/email`;
67+
const emailClaim = `${fastify.config.AUTH0_CLIENT_CLAIMS}/email`;
7368

74-
if (!req.user) {
75-
req.appUserOptional = null;
76-
return;
77-
}
78-
79-
const email = req.user[emailClaim] as string;
69+
if (!req.user) {
70+
req.appUserOptional = null;
71+
return;
72+
}
8073

81-
if (!email) {
82-
throw fastify.httpErrors.badRequest('No valid user data');
83-
}
74+
const email = req.user[emailClaim] as string;
8475

85-
const user = await fastify.prisma.user.findFirst({
86-
where: {
87-
email,
88-
},
89-
});
76+
if (!email) {
77+
throw fastify.httpErrors.badRequest('No valid user data');
78+
}
9079

91-
if (!user) {
92-
req.appUser = await fastify.prisma.user.create({
93-
data: {
80+
const user = await fastify.prisma.user.findFirst({
81+
where: {
9482
email,
9583
},
9684
});
97-
} else {
98-
req.appUser = user;
99-
}
10085

101-
req.appUserOptional = req.appUser;
102-
}
86+
if (!user) {
87+
req.appUser = await fastify.prisma.user.create({
88+
data: {
89+
email,
90+
},
91+
});
92+
} else {
93+
req.appUser = user;
94+
}
95+
96+
req.appUserOptional = req.appUser;
97+
};
10398

104-
fastify.decorateRequest('appUser', null);
105-
fastify.decorate('authorize', authorize);
99+
fastify.decorate('verifyAuth0', authorize);
106100
},
107101
);
108102

109103
declare module 'fastify' {
110104
interface FastifyInstance {
111-
authorize: (
112-
req: FastifyRequest,
113-
reply: FastifyReply,
114-
options?: AuthorizeOptions,
115-
) => void;
105+
verifyAuth0: (options?: AuthorizeOptions) => preHandlerHookHandler;
116106
}
117-
118107
interface FastifyRequest {
119108
appUser: User;
120109
appUserOptional: User | null;
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import {ManagementClient} from 'auth0';
2+
import fp from 'fastify-plugin';
3+
4+
export default fp(async fastify => {
5+
fastify.decorate(
6+
'auth0Management',
7+
new ManagementClient({
8+
domain: fastify.config.DOMAIN_AUTH0!,
9+
audience: fastify.config.AUDIENCE_AUTH0!,
10+
clientId: fastify.config.CLIENT_ID_AUTH0!,
11+
clientSecret: fastify.config.CLIENT_SECRET_AUTH0!,
12+
}),
13+
);
14+
});
15+
16+
declare module 'fastify' {
17+
interface FastifyInstance {
18+
auth0Management: ManagementClient;
19+
}
20+
}

apps/api/src/plugins/multi-auth.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import fastifyAuth from '@fastify/auth';
2+
import {preHandlerHookHandler} from 'fastify';
3+
import fp from 'fastify-plugin';
4+
5+
interface AuthorizeOptions {
6+
mustBeAuthenticated: boolean;
7+
}
8+
export default fp(async fastify => {
9+
fastify.register(fastifyAuth);
10+
11+
const preHookHandler: (options: AuthorizeOptions) => preHandlerHookHandler = (
12+
options = {mustBeAuthenticated: true},
13+
) =>
14+
function (request, reply, done) {
15+
return fastify
16+
.auth([
17+
fastify.verifyAuth0(options),
18+
fastify.verifyHankoPasskey(options),
19+
])
20+
.apply(this, [request, reply, done]);
21+
};
22+
23+
fastify.decorate('authorize', preHookHandler);
24+
});
25+
26+
declare module 'fastify' {
27+
interface FastifyInstance {
28+
authorize: (options?: AuthorizeOptions) => preHandlerHookHandler;
29+
}
30+
}

apps/api/src/plugins/passkeys.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import {tenant} from '@teamhanko/passkeys-sdk';
2+
import {preHandlerHookHandler} from 'fastify';
3+
import fp from 'fastify-plugin';
4+
5+
interface AuthorizeOptions {
6+
mustBeAuthenticated: boolean;
7+
}
8+
9+
export const passkeysPlugin = fp(async fastify => {
10+
const passkeysApi = tenant({
11+
tenantId: fastify.config.HANKO_PASSKEYS_TENANT_ID,
12+
apiKey: fastify.config.HANKO_PASSKEYS_API_KEY,
13+
baseUrl: fastify.config.HANKO_PASSKEYS_LOGIN_BASE_URL,
14+
});
15+
16+
fastify.decorate('passkeysApi', passkeysApi);
17+
18+
const verify: (options: AuthorizeOptions) => preHandlerHookHandler =
19+
(options = {mustBeAuthenticated: true}) =>
20+
async (req, reply, done) => {
21+
const token = req.headers.authorization
22+
?.split('Bearer ')[1]
23+
.split('.')[1] as string;
24+
const claims = JSON.parse(atob(token));
25+
const userId = claims.sub;
26+
27+
const user = await fastify.prisma.user.findFirst({
28+
where: {
29+
id: userId,
30+
},
31+
});
32+
33+
if (user) {
34+
console.log('augment request with user', user);
35+
req.appUser = user;
36+
req.appUserOptional = user;
37+
done();
38+
} else if (options.mustBeAuthenticated) {
39+
throw fastify.httpErrors.unauthorized();
40+
}
41+
};
42+
43+
fastify.decorate('verifyHankoPasskey', verify);
44+
});
45+
46+
export default passkeysPlugin;
47+
48+
declare module 'fastify' {
49+
interface FastifyInstance {
50+
passkeysApi: ReturnType<typeof tenant>;
51+
52+
verifyHankoPasskey: (
53+
options?: AuthorizeOptions,
54+
) => (req: FastifyRequest, reply: FastifyReply) => void;
55+
}
56+
}

apps/api/src/plugins/user.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import fp from 'fastify-plugin';
2+
3+
export default fp(async fastify => {
4+
fastify.decorateRequest('appUser', null);
5+
fastify.decorateRequest('appUserOptional', null);
6+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import {FastifyPluginAsyncTypebox} from '@fastify/type-provider-typebox';
2+
import {Type} from '@sinclair/typebox';
3+
4+
const route: FastifyPluginAsyncTypebox = async fastify => {
5+
fastify.delete(
6+
'/credentials',
7+
{
8+
schema: {
9+
params: Type.Object({
10+
credentialId: Type.String(),
11+
}),
12+
},
13+
preValidation: function (request, reply, done) {
14+
return fastify
15+
.auth([fastify.authenticate, fastify.verifyHankoPasskey])
16+
.apply(this, [request, reply, done]);
17+
},
18+
},
19+
async request => {
20+
return fastify.passkeysApi.credential(request.params.credentialId);
21+
},
22+
);
23+
};
24+
25+
export default route;

0 commit comments

Comments
 (0)