feat: implement TASK-WP1-02 login sessions

This commit is contained in:
suyx
2026-07-28 16:28:30 +08:00
parent f467e7c09f
commit ee70001d44
20 changed files with 2480 additions and 28 deletions
+160 -3
View File
@@ -10,6 +10,11 @@ import {
ErrorDetailsSchema,
ErrorEnvelopeSchema,
GenerationErrorCategorySchema,
LoginCompleteRequestSchema,
LoginCompleteResponseSchema,
LoginSendRequestSchema,
LogoutHeadersSchema,
LogoutResponseSchema,
ModelConfigSseEventSchema,
ModelRuntimeSseEventSchema,
RegistrationCompleteHeadersSchema,
@@ -25,6 +30,8 @@ import {
createErrorEnvelope,
isCorrelationId,
type BootstrapResponse,
type LoginCompleteRequest,
type LoginSendRequest,
type RegistrationCompleteRequest,
type RegistrationSendRequest,
} from "@dada/shared-contracts";
@@ -92,6 +99,8 @@ const contentSecurityPolicy = [
"base-uri 'none'",
"frame-ancestors 'none'",
].join("; ");
const authFlowCookieName = "dada_auth_flow";
const userSessionCookieName = "dada_session";
function requestCorrelationId(headers: Record<string, string | string[] | undefined>) {
const header = headers["x-correlation-id"];
@@ -208,6 +217,11 @@ export async function createApp(options: CreateAppOptions = {}) {
RegistrationCompleteRequestSchema,
RegistrationCompleteHeadersSchema,
RegistrationCompleteResponseSchema,
LoginSendRequestSchema,
LoginCompleteRequestSchema,
LoginCompleteResponseSchema,
LogoutHeadersSchema,
LogoutResponseSchema,
UserSessionResponseSchema,
BrowserUnsupportedReasonSchema,
BrowserSupportRequestSchema,
@@ -288,6 +302,111 @@ export async function createApp(options: CreateAppOptions = {}) {
},
);
app.post(
"/api/v1/auth/login/send",
{
attachValidation: true,
schema: {
body: Type.Ref(LoginSendRequestSchema),
operationId: "sendLoginCode",
response: {
200: Type.Ref(RegistrationSendResponseSchema),
400: Type.Ref(ErrorEnvelopeSchema),
409: Type.Ref(ErrorEnvelopeSchema),
429: Type.Ref(ErrorEnvelopeSchema),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Authentication"],
},
},
async (request, reply) => {
if (request.validationError) return registrationValidationFailure(reply, request.id);
if (!options.registration) {
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
const existingFlow = cookieValue(headerValue(request.headers.cookie), authFlowCookieName);
const clientKey = existingFlow ?? randomBytes(32).toString("base64url");
try {
const body = request.body as LoginSendRequest;
const result = await options.registration.sendLoginCode({ clientKey, email: body.email });
if (!existingFlow) {
reply.header(
"Set-Cookie",
`${authFlowCookieName}=${clientKey}; Max-Age=${10 * 60}; Path=/; HttpOnly; SameSite=Strict`,
);
}
return {
challenge_expires_at: new Date(result.challengeExpiresAt).toISOString(),
registration_id: result.registrationId,
resend_available_at: new Date(result.resendAvailableAt).toISOString(),
status: result.status,
};
} catch (error) {
return registrationFailure(reply, request.id, error);
}
},
);
app.post(
"/api/v1/auth/login/complete",
{
attachValidation: true,
schema: {
body: Type.Ref(LoginCompleteRequestSchema),
headers: Type.Ref(RegistrationCompleteHeadersSchema),
operationId: "completeLogin",
response: {
200: Type.Ref(LoginCompleteResponseSchema),
400: Type.Ref(ErrorEnvelopeSchema),
409: Type.Ref(ErrorEnvelopeSchema),
429: Type.Ref(ErrorEnvelopeSchema),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Authentication"],
},
},
async (request, reply) => {
if (request.validationError) return registrationValidationFailure(reply, request.id);
if (!options.registration) {
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
const clientKey = cookieValue(headerValue(request.headers.cookie), authFlowCookieName);
const idempotencyKey = headerValue(request.headers["idempotency-key"]);
if (!clientKey || !idempotencyKey) return registrationValidationFailure(reply, request.id);
try {
const body = request.body as LoginCompleteRequest;
const result = options.registration.completeLogin({
clientKey,
code: body.verification_code,
idempotencyKey,
registrationId: body.registration_id,
});
reply.header(
"Set-Cookie",
`${userSessionCookieName}=${result.sessionToken}; Max-Age=${30 * 24 * 60 * 60}; Path=/; HttpOnly; SameSite=Strict`,
);
return {
audience: result.audience,
credits: {
available_balance: result.credits.availableBalance,
reserved_balance: result.credits.reservedBalance,
},
session_expires_at: new Date(result.sessionExpiresAt).toISOString(),
status: result.status,
user: {
creator_name: result.user.creatorName,
role: result.user.role,
social_id: result.user.socialId,
status: result.user.status,
user_id: result.user.userId,
},
};
} catch (error) {
return registrationFailure(reply, request.id, error);
}
},
);
app.post(
"/api/v1/auth/register/send",
{
@@ -299,6 +418,7 @@ export async function createApp(options: CreateAppOptions = {}) {
200: Type.Ref(RegistrationSendResponseSchema),
400: Type.Ref(ErrorEnvelopeSchema),
409: Type.Ref(ErrorEnvelopeSchema),
429: Type.Ref(ErrorEnvelopeSchema),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Authentication"],
@@ -361,7 +481,7 @@ export async function createApp(options: CreateAppOptions = {}) {
});
reply.header(
"Set-Cookie",
`dada_session=${result.sessionToken}; Max-Age=${30 * 24 * 60 * 60}; Path=/; HttpOnly; SameSite=Strict`,
`${userSessionCookieName}=${result.sessionToken}; Max-Age=${30 * 24 * 60 * 60}; Path=/; HttpOnly; SameSite=Strict`,
);
return {
credits: {
@@ -401,7 +521,7 @@ export async function createApp(options: CreateAppOptions = {}) {
if (!options.registration) {
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
const token = cookieValue(headerValue(request.headers.cookie), "dada_session");
const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName);
const session = token ? options.registration.readUserSession(token) : undefined;
if (!session) {
return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
@@ -413,7 +533,7 @@ export async function createApp(options: CreateAppOptions = {}) {
available_balance: session.credits.availableBalance,
reserved_balance: session.credits.reservedBalance,
},
csrf_token: randomBytes(32).toString("base64url"),
csrf_token: options.registration.issueUserCsrfToken(token!),
expires_at: new Date(session.expiresAt).toISOString(),
user: {
creator_name: session.user.creatorName,
@@ -426,6 +546,43 @@ export async function createApp(options: CreateAppOptions = {}) {
},
);
app.post(
"/api/v1/auth/logout",
{
attachValidation: true,
schema: {
headers: Type.Ref(LogoutHeadersSchema),
operationId: "logoutUser",
response: {
200: Type.Ref(LogoutResponseSchema),
400: Type.Ref(ErrorEnvelopeSchema),
401: Type.Ref(ErrorEnvelopeSchema),
403: Type.Ref(ErrorEnvelopeSchema),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Authentication"],
},
},
async (request, reply) => {
if (request.validationError) return registrationValidationFailure(reply, request.id);
if (!options.registration) {
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName);
const csrfToken = headerValue(request.headers["x-csrf-token"]);
if (!token || !csrfToken) {
return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
}
try {
options.registration.logoutUser({ csrfToken, sessionToken: token });
reply.header("Set-Cookie", `${userSessionCookieName}=; Max-Age=0; Path=/; HttpOnly; SameSite=Strict`);
return { status: "logged_out" as const };
} catch (error) {
return registrationFailure(reply, request.id, error);
}
},
);
app.post(
"/api/v1/support/check",
{