feat(platform-service): add SSO login endpoint (/auth/sso)
- POST /auth/sso — accepts verified email + provider + productId - Creates user if not exists (with subscription + license provisioning) - Issues platform JWT tokens for existing SSO users - Supports Microsoft and Google OAuth providers - Added SsoLoginSchema to types.ts
This commit is contained in:
parent
c7fb2eb357
commit
be3f5459bd
@ -5,6 +5,7 @@
|
|||||||
* POST /auth/register — register new user
|
* POST /auth/register — register new user
|
||||||
* POST /auth/refresh — refresh access token
|
* POST /auth/refresh — refresh access token
|
||||||
* GET /auth/me — get current user from token
|
* GET /auth/me — get current user from token
|
||||||
|
* POST /auth/sso — SSO login (verified external identity)
|
||||||
* POST /auth/verify — service-to-service token verification
|
* POST /auth/verify — service-to-service token verification
|
||||||
*
|
*
|
||||||
* Admin user management (requires super_admin or admin role):
|
* Admin user management (requires super_admin or admin role):
|
||||||
@ -27,6 +28,7 @@ import {
|
|||||||
RegisterSchema,
|
RegisterSchema,
|
||||||
RefreshSchema,
|
RefreshSchema,
|
||||||
UpdateUserSchema,
|
UpdateUserSchema,
|
||||||
|
SsoLoginSchema,
|
||||||
type UserDoc,
|
type UserDoc,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|
||||||
@ -229,6 +231,113 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// SSO login — verified external identity (called by dashboard OAuth callbacks)
|
||||||
|
// The caller has already verified the user's identity via Microsoft/Google OAuth.
|
||||||
|
// This endpoint finds or creates the user and issues platform JWT tokens.
|
||||||
|
app.post('/auth/sso', async (req, reply) => {
|
||||||
|
const parsed = SsoLoginSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||||
|
}
|
||||||
|
const { email, productId, provider, displayName } = parsed.data;
|
||||||
|
const product = getProduct(productId);
|
||||||
|
if (!product || product.status !== 'active') {
|
||||||
|
throw new BadRequestError(`Unknown or disabled product: ${productId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = await repo.getByEmail(email, productId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
// Auto-create SSO user with a random password (they'll never use it)
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
user = {
|
||||||
|
id: `usr_${crypto.randomUUID()}`,
|
||||||
|
productId,
|
||||||
|
email: email.toLowerCase(),
|
||||||
|
passwordHash: await repo.hashPassword(crypto.randomUUID()),
|
||||||
|
plan: product.defaultPlan,
|
||||||
|
role: 'user',
|
||||||
|
displayName: displayName || email.split('@')[0],
|
||||||
|
status: 'active',
|
||||||
|
lastLoginAt: now,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
await repo.create(user);
|
||||||
|
|
||||||
|
// Best-effort: provision subscription + license (same as register)
|
||||||
|
const nowDate = new Date();
|
||||||
|
const trialEnd = new Date(nowDate);
|
||||||
|
trialEnd.setDate(trialEnd.getDate() + product.trialDays);
|
||||||
|
const hasTrial = product.trialDays > 0;
|
||||||
|
try {
|
||||||
|
await subscriptionRepo.createSubscription({
|
||||||
|
id: `sub_${user.id}_${Date.now()}`,
|
||||||
|
productId,
|
||||||
|
userId: user.id,
|
||||||
|
plan: product.defaultPlan,
|
||||||
|
status: hasTrial ? 'trialing' : 'active',
|
||||||
|
currentPeriodStart: now,
|
||||||
|
currentPeriodEnd: hasTrial ? trialEnd.toISOString() : now,
|
||||||
|
cancelAtPeriodEnd: false,
|
||||||
|
monthlyPrice: 0,
|
||||||
|
tokensIncluded: 0,
|
||||||
|
tokensUsed: 0,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
req.log.warn({ err, userId: user.id }, 'SSO subscription provisioning failed');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await licenseRepo.create({
|
||||||
|
id: `lic_${crypto.randomUUID()}`,
|
||||||
|
productId,
|
||||||
|
key: licenseRepo.generateKey(product.licensePrefix),
|
||||||
|
userId: user.id,
|
||||||
|
plan: product.defaultPlan,
|
||||||
|
status: 'active',
|
||||||
|
activatedAt: null,
|
||||||
|
expiresAt: hasTrial ? trialEnd.toISOString() : null,
|
||||||
|
deviceIds: [],
|
||||||
|
maxDevices: product.deviceLimits[product.defaultPlan],
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
req.log.warn({ err, userId: user.id }, 'SSO license provisioning failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.code(201);
|
||||||
|
} else {
|
||||||
|
// Existing user — update last login
|
||||||
|
await repo.updateLastLogin(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
req.log.info({ userId: user.id, provider, email }, 'SSO login');
|
||||||
|
|
||||||
|
const accessToken = await jwt.createAccessToken({
|
||||||
|
sub: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
productId,
|
||||||
|
plan: user.plan,
|
||||||
|
});
|
||||||
|
const refreshToken = await jwt.createRefreshToken({ sub: user.id, productId });
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
plan: user.plan,
|
||||||
|
displayName: user.displayName,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Service-to-service token verification
|
// Service-to-service token verification
|
||||||
app.post('/auth/verify', async req => {
|
app.post('/auth/verify', async req => {
|
||||||
const { token } = req.body as { token?: string };
|
const { token } = req.body as { token?: string };
|
||||||
|
|||||||
@ -53,6 +53,14 @@ export const UpdateUserSchema = z.object({
|
|||||||
status: z.enum(['active', 'disabled']).optional(),
|
status: z.enum(['active', 'disabled']).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const SsoLoginSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
productId: z.string().min(1),
|
||||||
|
provider: z.enum(['microsoft', 'google']),
|
||||||
|
displayName: z.string().min(1).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
export type LoginInput = z.infer<typeof LoginSchema>;
|
export type LoginInput = z.infer<typeof LoginSchema>;
|
||||||
export type RegisterInput = z.infer<typeof RegisterSchema>;
|
export type RegisterInput = z.infer<typeof RegisterSchema>;
|
||||||
export type UpdateUserInput = z.infer<typeof UpdateUserSchema>;
|
export type UpdateUserInput = z.infer<typeof UpdateUserSchema>;
|
||||||
|
export type SsoLoginInput = z.infer<typeof SsoLoginSchema>;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user