|
| 1 | +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; |
| 2 | +import { z } from 'zod'; |
| 3 | +import { createSchema } from 'zod-openapi'; |
| 4 | +import { requireValidAccessToken, requireOAuthScope } from '../../middleware/oauthMiddleware'; |
| 5 | +import { UserService } from '../../services/userService'; |
| 6 | + |
| 7 | +// OAuth2 UserInfo response schema (RFC 6749 / OpenID Connect standard) |
| 8 | +const userInfoResponseSchema = z.object({ |
| 9 | + sub: z.string().describe('Subject identifier - unique user ID'), |
| 10 | + email: z.string().email().describe('User email address'), |
| 11 | + name: z.string().optional().describe('Full name of the user'), |
| 12 | + preferred_username: z.string().describe('Preferred username'), |
| 13 | + email_verified: z.boolean().describe('Whether the email address has been verified'), |
| 14 | + given_name: z.string().optional().describe('Given name (first name)'), |
| 15 | + family_name: z.string().optional().describe('Family name (last name)') |
| 16 | +}); |
| 17 | + |
| 18 | +// Error response schema for OAuth2 errors |
| 19 | +const oauthErrorResponseSchema = z.object({ |
| 20 | + error: z.string().describe('OAuth2 error code'), |
| 21 | + error_description: z.string().describe('Human-readable error description') |
| 22 | +}); |
| 23 | + |
| 24 | +export default async function userinfoRoute(fastify: FastifyInstance) { |
| 25 | + const userService = new UserService(); |
| 26 | + |
| 27 | + // GET /oauth2/userinfo - Standard OAuth2 UserInfo endpoint |
| 28 | + fastify.get('/oauth2/userinfo', { |
| 29 | + schema: { |
| 30 | + tags: ['OAuth2'], |
| 31 | + summary: 'Get user information', |
| 32 | + description: 'Returns user information for the authenticated user. This is the standard OAuth2/OpenID Connect UserInfo endpoint. Requires a valid OAuth2 access token with user:read scope.', |
| 33 | + security: [{ bearerAuth: [] }], |
| 34 | + response: { |
| 35 | + 200: createSchema(userInfoResponseSchema.describe('User information retrieved successfully')), |
| 36 | + 401: createSchema(oauthErrorResponseSchema.describe('Unauthorized - Invalid or missing access token')), |
| 37 | + 403: createSchema(oauthErrorResponseSchema.describe('Forbidden - Insufficient scope')), |
| 38 | + 404: createSchema(oauthErrorResponseSchema.describe('Not Found - User not found')), |
| 39 | + 500: createSchema(oauthErrorResponseSchema.describe('Internal Server Error')) |
| 40 | + } |
| 41 | + }, |
| 42 | + preValidation: [ |
| 43 | + requireValidAccessToken(), |
| 44 | + requireOAuthScope('user:read') |
| 45 | + ] |
| 46 | + }, async (request: FastifyRequest, reply: FastifyReply) => { |
| 47 | + try { |
| 48 | + // At this point, the user is authenticated via OAuth2 and has the required scope |
| 49 | + if (!request.tokenPayload) { |
| 50 | + fastify.log.error({ |
| 51 | + operation: 'oauth2_userinfo', |
| 52 | + error: 'Missing token payload after validation' |
| 53 | + }, 'OAuth2 userinfo: Missing token payload'); |
| 54 | + |
| 55 | + const errorResponse = { |
| 56 | + error: 'server_error', |
| 57 | + error_description: 'Internal authentication error' |
| 58 | + }; |
| 59 | + const jsonString = JSON.stringify(errorResponse); |
| 60 | + return reply.status(500).type('application/json').send(jsonString); |
| 61 | + } |
| 62 | + |
| 63 | + const userId = request.tokenPayload.user.id; |
| 64 | + const userEmail = request.tokenPayload.user.email; |
| 65 | + |
| 66 | + fastify.log.debug({ |
| 67 | + operation: 'oauth2_userinfo', |
| 68 | + userId, |
| 69 | + userEmail, |
| 70 | + clientId: request.tokenPayload.clientId, |
| 71 | + scope: request.tokenPayload.scope |
| 72 | + }, `User ${userEmail} retrieving userinfo via OAuth2`); |
| 73 | + |
| 74 | + // Get full user data from database |
| 75 | + const user = await userService.getUserById(userId); |
| 76 | + |
| 77 | + if (!user) { |
| 78 | + fastify.log.warn({ |
| 79 | + operation: 'oauth2_userinfo', |
| 80 | + userId, |
| 81 | + userEmail |
| 82 | + }, 'OAuth2 userinfo: User not found in database'); |
| 83 | + |
| 84 | + const errorResponse = { |
| 85 | + error: 'invalid_token', |
| 86 | + error_description: 'User associated with token not found' |
| 87 | + }; |
| 88 | + const jsonString = JSON.stringify(errorResponse); |
| 89 | + return reply.status(404).type('application/json').send(jsonString); |
| 90 | + } |
| 91 | + |
| 92 | + // Build full name from first_name and last_name |
| 93 | + let fullName: string | undefined; |
| 94 | + if (user.first_name || user.last_name) { |
| 95 | + const parts = []; |
| 96 | + if (user.first_name) parts.push(user.first_name); |
| 97 | + if (user.last_name) parts.push(user.last_name); |
| 98 | + fullName = parts.join(' '); |
| 99 | + } |
| 100 | + |
| 101 | + // Create OAuth2 UserInfo response following RFC standards |
| 102 | + const userInfoResponse = { |
| 103 | + sub: String(user.id), // Subject identifier (required) |
| 104 | + email: String(user.email), // Email address (required) |
| 105 | + preferred_username: String(user.username), // Username (required) |
| 106 | + email_verified: true, // Assume verified for now |
| 107 | + ...(fullName && { name: fullName }), // Full name (optional) |
| 108 | + ...(user.first_name && { given_name: String(user.first_name) }), // First name (optional) |
| 109 | + ...(user.last_name && { family_name: String(user.last_name) }) // Last name (optional) |
| 110 | + }; |
| 111 | + |
| 112 | + fastify.log.info({ |
| 113 | + operation: 'oauth2_userinfo', |
| 114 | + userId, |
| 115 | + userEmail, |
| 116 | + clientId: request.tokenPayload.clientId, |
| 117 | + responseFields: Object.keys(userInfoResponse) |
| 118 | + }, `OAuth2 userinfo retrieved successfully for user ${userEmail}`); |
| 119 | + |
| 120 | + const jsonString = JSON.stringify(userInfoResponse); |
| 121 | + return reply.status(200).type('application/json').send(jsonString); |
| 122 | + |
| 123 | + } catch (error) { |
| 124 | + fastify.log.error({ |
| 125 | + operation: 'oauth2_userinfo', |
| 126 | + error, |
| 127 | + userId: request.tokenPayload?.user.id, |
| 128 | + userEmail: request.tokenPayload?.user.email |
| 129 | + }, 'OAuth2 userinfo: Unexpected error'); |
| 130 | + |
| 131 | + const errorResponse = { |
| 132 | + error: 'server_error', |
| 133 | + error_description: 'An error occurred while retrieving user information' |
| 134 | + }; |
| 135 | + const jsonString = JSON.stringify(errorResponse); |
| 136 | + return reply.status(500).type('application/json').send(jsonString); |
| 137 | + } |
| 138 | + }); |
| 139 | +} |
0 commit comments