feat: complete Tasks 21-28 — backend integration, security hardening, UI tests & CI
- Add Apple Sign-In backend (JWKS verification, account linking, session management) - Implement push notification deep linking with NotificationDeepLinkRouter - Add jailbreak detection, runtime integrity monitoring, secure enclave service - Implement OAuth social login, token refresh, and secure logout flows - Add image caching (memory/disk), optimizer, upload queue, async semaphore - Implement notification analytics, type preferences, and category setup - Expand UI test suite with UITestBase, accessibility, auth flow, performance tests - Add CI pipeline for iOS UI tests (3 device sizes) and performance benchmarks - Restructure Xcode project to manual groups with KordantWidgets target - Add SwiftLint, Swift Collections/Algorithms/GoogleSignIn dependencies - Update project.yml for XcodeGen with new targets and configurations
This commit is contained in:
@@ -2,6 +2,7 @@ import type { APIEvent } from "@solidjs/start/server";
|
||||
import {
|
||||
authenticateUser,
|
||||
authenticateWithGoogle,
|
||||
authenticateWithApple,
|
||||
createUserWithPassword,
|
||||
forgotPassword,
|
||||
resetPassword,
|
||||
@@ -94,6 +95,31 @@ export async function POST(event: APIEvent) {
|
||||
});
|
||||
}
|
||||
|
||||
case "apple": {
|
||||
const { identityToken, authorizationCode, userIdentifier } = body;
|
||||
if (!identityToken || !authorizationCode) {
|
||||
return new Response(
|
||||
JSON.stringify({ message: "identityToken and authorizationCode are required" }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
const result = await authenticateWithApple(
|
||||
identityToken,
|
||||
authorizationCode,
|
||||
userIdentifier ?? null,
|
||||
);
|
||||
return Response.json({
|
||||
id: result.user.id,
|
||||
name: result.user.name ?? "",
|
||||
email: result.user.email,
|
||||
image: result.user.image,
|
||||
accessToken: result.accessToken,
|
||||
refreshToken: result.refreshToken,
|
||||
sessionToken: result.sessionToken,
|
||||
isNewUser: result.isNewUser ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
case "refresh": {
|
||||
const { refreshToken } = body;
|
||||
if (!refreshToken) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
createUserWithPassword,
|
||||
authenticateUser,
|
||||
authenticateWithGoogle,
|
||||
authenticateWithApple,
|
||||
refreshAccessToken,
|
||||
forgotPassword,
|
||||
resetPassword,
|
||||
@@ -42,6 +43,12 @@ const GoogleAuthSchema = object({
|
||||
idToken: string([minLength(1)]),
|
||||
});
|
||||
|
||||
const AppleAuthSchema = object({
|
||||
identityToken: string([minLength(1)]),
|
||||
authorizationCode: string([minLength(1)]),
|
||||
userIdentifier: string(),
|
||||
});
|
||||
|
||||
const RefreshTokenSchema = object({
|
||||
refreshToken: string([minLength(1)]),
|
||||
});
|
||||
@@ -74,6 +81,16 @@ export const userRouter = createTRPCRouter({
|
||||
return authenticateWithGoogle(input.idToken);
|
||||
}),
|
||||
|
||||
appleAuth: publicProcedure
|
||||
.input(wrap(AppleAuthSchema))
|
||||
.mutation(async ({ input }) => {
|
||||
return authenticateWithApple(
|
||||
input.identityToken,
|
||||
input.authorizationCode,
|
||||
input.userIdentifier || null,
|
||||
);
|
||||
}),
|
||||
|
||||
refreshToken: publicProcedure
|
||||
.input(wrap(RefreshTokenSchema))
|
||||
.mutation(async ({ input }) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq, and, isNull } from "drizzle-orm";
|
||||
import { createRemoteJWKSet, jwtVerify } from "jose";
|
||||
import { db } from "~/server/db";
|
||||
import { users, accounts } from "~/server/db/schema/auth";
|
||||
import { hashPassword, verifyPassword } from "~/server/auth/password";
|
||||
@@ -67,6 +68,8 @@ export async function authenticateUser(
|
||||
}
|
||||
|
||||
const GOOGLE_ISSUER = "https://accounts.google.com";
|
||||
const APPLE_ISSUER = "https://appleid.apple.com";
|
||||
const APPLE_JWKS_URL = new URL("https://appleid.apple.com/auth/keys");
|
||||
|
||||
/**
|
||||
* Verifies a Google ID token using firebase-admin and returns the user.
|
||||
@@ -207,6 +210,137 @@ export async function authenticateWithGoogle(idToken: string) {
|
||||
return { user, sessionToken: session.sessionToken, accessToken, refreshToken, isNewUser };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies an Apple identity token and authenticates the user.
|
||||
* If the user does not exist, creates a new account.
|
||||
* If the user exists but has not linked Apple, links the provider.
|
||||
*/
|
||||
export async function authenticateWithApple(
|
||||
identityToken: string,
|
||||
authorizationCode: string,
|
||||
userIdentifier?: string | null,
|
||||
) {
|
||||
if (!identityToken) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Missing identity token",
|
||||
});
|
||||
}
|
||||
|
||||
// Verify Apple ID token using Apple's JWKS
|
||||
let payload: { sub: string; email?: string; is_private_email?: string; };
|
||||
try {
|
||||
const JWKS = createRemoteJWKSet(APPLE_JWKS_URL);
|
||||
const result = await jwtVerify(identityToken, JWKS, {
|
||||
issuer: APPLE_ISSUER,
|
||||
audience: process.env.IOS_BUNDLE_ID ?? "com.frenocorp.kordant",
|
||||
});
|
||||
payload = result.payload as unknown as { sub: string; email?: string; is_private_email?: string; };
|
||||
} catch (err) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid Apple identity token",
|
||||
});
|
||||
}
|
||||
|
||||
const appleUserId = payload.sub;
|
||||
const email = payload.email ?? null;
|
||||
|
||||
if (!email) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Apple account has no email address",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if this Apple account is already linked
|
||||
const [existingAccount] = await db
|
||||
.select()
|
||||
.from(accounts)
|
||||
.where(
|
||||
and(
|
||||
eq(accounts.provider, "apple"),
|
||||
eq(accounts.providerAccountId, appleUserId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
let userId: string;
|
||||
let isNewUser = false;
|
||||
|
||||
if (existingAccount) {
|
||||
// Already linked — use the existing user
|
||||
userId = existingAccount.userId;
|
||||
isNewUser = false;
|
||||
|
||||
// Update tokens
|
||||
await db
|
||||
.update(accounts)
|
||||
.set({
|
||||
accessToken: identityToken,
|
||||
refreshToken: authorizationCode,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(accounts.id, existingAccount.id));
|
||||
} else {
|
||||
// Not linked — check if a user with this email exists
|
||||
const [existingUserByEmail] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(and(eq(users.email, email), isNull(users.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
// Apple provides the user's first name and last name only on the initial sign-up
|
||||
// We derive a display name from email if userIdentifier-based lookup doesn't work
|
||||
const displayName = email.split("@")[0] ?? "User";
|
||||
|
||||
if (existingUserByEmail) {
|
||||
// Link Apple to existing user
|
||||
userId = existingUserByEmail.id;
|
||||
isNewUser = false;
|
||||
await db.insert(accounts).values({
|
||||
userId,
|
||||
provider: "apple",
|
||||
providerAccountId: appleUserId,
|
||||
accessToken: identityToken,
|
||||
refreshToken: authorizationCode,
|
||||
});
|
||||
} else {
|
||||
// Create new user with Apple
|
||||
isNewUser = true;
|
||||
const [newUser] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
name: displayName,
|
||||
email,
|
||||
emailVerified: new Date(),
|
||||
})
|
||||
.returning();
|
||||
userId = newUser.id;
|
||||
|
||||
await db.insert(accounts).values({
|
||||
userId,
|
||||
provider: "apple",
|
||||
providerAccountId: appleUserId,
|
||||
accessToken: identityToken,
|
||||
refreshToken: authorizationCode,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create session and JWT
|
||||
const session = await createSession(userId);
|
||||
const accessToken = await signJWT({ sub: userId }, { expiresIn: "7d" });
|
||||
const refreshToken = await signJWT({ sub: userId, type: "refresh" }, { expiresIn: "30d" });
|
||||
|
||||
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
|
||||
if (!user) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "User not found after creation" });
|
||||
}
|
||||
|
||||
return { user, sessionToken: session.sessionToken, accessToken, refreshToken, isNewUser };
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes an access token using a valid refresh token.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user