feat: implement TASK-WP1-02 login sessions
This commit is contained in:
+160
-3
@@ -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",
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user