Skip to content

Commit 40e88c8

Browse files
author
Lasim
committed
feat(backend): add userinfo route and extend token expiration to 1 week
1 parent 98b54fb commit 40e88c8

File tree

4 files changed

+146
-5
lines changed

4 files changed

+146
-5
lines changed

services/backend/src/routes/oauth2/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ import { type FastifyInstance } from 'fastify';
22
import authorizationRoute from './authorization';
33
import tokenRoute from './token';
44
import consentRoute from './consent';
5+
import userinfoRoute from './userinfo';
56

67
export default async function oauth2Routes(fastify: FastifyInstance) {
78
// Register OAuth2 routes
89
await fastify.register(authorizationRoute);
910
await fastify.register(tokenRoute);
1011
await fastify.register(consentRoute);
12+
await fastify.register(userinfoRoute);
1113
}

services/backend/src/routes/oauth2/token.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ export default async function tokenRoute(fastify: FastifyInstance) {
125125
const tokenResponse = {
126126
access_token: accessToken,
127127
token_type: 'Bearer' as const,
128-
expires_in: 3600, // 1 hour
128+
expires_in: 7 * 24 * 3600, // 1 week
129129
refresh_token: refreshToken,
130130
scope: authCode.scope
131131
};
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
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+
}

services/backend/src/services/oauth/tokenService.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ export class TokenService {
8585
const tokenData = {
8686
...payload,
8787
iat: Math.floor(Date.now() / 1000),
88-
exp: Math.floor(Date.now() / 1000) + 3600, // 1 hour
88+
exp: Math.floor(Date.now() / 1000) + (7 * 24 * 3600), // 1 week
8989
};
9090

9191
const accessToken = `${rawToken}.${Buffer.from(JSON.stringify(tokenData)).toString('base64')}`;
@@ -99,7 +99,7 @@ export class TokenService {
9999
});
100100

101101
// Store in database
102-
const expiresAt = new Date(Date.now() + 3600 * 1000); // 1 hour
102+
const expiresAt = new Date(Date.now() + 7 * 24 * 3600 * 1000); // 1 week
103103
await (db as any).insert(schema.oauthAccessTokens).values({
104104
id: tokenId,
105105
user_id: userId,
@@ -202,7 +202,7 @@ export class TokenService {
202202
return null;
203203
}
204204

205-
const [encodedPayload] = parts;
205+
const [, encodedPayload] = parts;
206206

207207
// Decode payload
208208
let payload: AccessTokenPayload & { iat: number; exp: number };
@@ -368,7 +368,7 @@ export class TokenService {
368368
return {
369369
access_token: accessToken,
370370
token_type: 'Bearer',
371-
expires_in: 3600,
371+
expires_in: 7 * 24 * 3600, // 1 week in seconds
372372
refresh_token: newRefreshToken,
373373
scope,
374374
};

0 commit comments

Comments
 (0)