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/refresh — refresh access 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
|
||||
*
|
||||
* Admin user management (requires super_admin or admin role):
|
||||
@ -27,6 +28,7 @@ import {
|
||||
RegisterSchema,
|
||||
RefreshSchema,
|
||||
UpdateUserSchema,
|
||||
SsoLoginSchema,
|
||||
type UserDoc,
|
||||
} 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
|
||||
app.post('/auth/verify', async req => {
|
||||
const { token } = req.body as { token?: string };
|
||||
|
||||
@ -53,6 +53,14 @@ export const UpdateUserSchema = z.object({
|
||||
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 RegisterInput = z.infer<typeof RegisterSchema>;
|
||||
export type UpdateUserInput = z.infer<typeof UpdateUserSchema>;
|
||||
export type SsoLoginInput = z.infer<typeof SsoLoginSchema>;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user