mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-05 17:33:31 -04:00
Added Sign In with Apple
This commit is contained in:
10
drizzle/0002_outstanding_hiroim.sql
Normal file
10
drizzle/0002_outstanding_hiroim.sql
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
ALTER TABLE `user` ADD `first_name` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `user` ADD `last_name` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `user` ADD `email` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `user` ADD `password_hash` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `user` ADD `apple_id` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `user` ADD `is_private` integer DEFAULT false;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `user_apple_id_unique` ON `user` (`apple_id`);--> statement-breakpoint
|
||||||
|
ALTER TABLE `user` DROP COLUMN `age`;--> statement-breakpoint
|
||||||
|
CREATE INDEX `anonymous_id_date_idx` ON `daily_completions` (`anonymous_id`,`date`);
|
||||||
275
drizzle/meta/0002_snapshot.json
Normal file
275
drizzle/meta/0002_snapshot.json
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "f3a47f60-540b-4d95-8c23-b1f68506b3ed",
|
||||||
|
"prevId": "569c1d8d-7308-47c2-ba44-85c4917b789d",
|
||||||
|
"tables": {
|
||||||
|
"daily_completions": {
|
||||||
|
"name": "daily_completions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"anonymous_id": {
|
||||||
|
"name": "anonymous_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"name": "date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"guess_count": {
|
||||||
|
"name": "guess_count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"completed_at": {
|
||||||
|
"name": "completed_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"anonymous_id_date_idx": {
|
||||||
|
"name": "anonymous_id_date_idx",
|
||||||
|
"columns": [
|
||||||
|
"anonymous_id",
|
||||||
|
"date"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"date_idx": {
|
||||||
|
"name": "date_idx",
|
||||||
|
"columns": [
|
||||||
|
"date"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"date_guess_idx": {
|
||||||
|
"name": "date_guess_idx",
|
||||||
|
"columns": [
|
||||||
|
"date",
|
||||||
|
"guess_count"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"daily_completions_anonymous_id_date_unique": {
|
||||||
|
"name": "daily_completions_anonymous_id_date_unique",
|
||||||
|
"columns": [
|
||||||
|
"anonymous_id",
|
||||||
|
"date"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"daily_verses": {
|
||||||
|
"name": "daily_verses",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"name": "date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"book_id": {
|
||||||
|
"name": "book_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"verse_text": {
|
||||||
|
"name": "verse_text",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"reference": {
|
||||||
|
"name": "reference",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"daily_verses_date_unique": {
|
||||||
|
"name": "daily_verses_date_unique",
|
||||||
|
"columns": [
|
||||||
|
"date"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"session": {
|
||||||
|
"name": "session",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"session_user_id_user_id_fk": {
|
||||||
|
"name": "session_user_id_user_id_fk",
|
||||||
|
"tableFrom": "session",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"name": "user",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"first_name": {
|
||||||
|
"name": "first_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_name": {
|
||||||
|
"name": "last_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"apple_id": {
|
||||||
|
"name": "apple_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"is_private": {
|
||||||
|
"name": "is_private",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"user_email_unique": {
|
||||||
|
"name": "user_email_unique",
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
},
|
||||||
|
"user_apple_id_unique": {
|
||||||
|
"name": "user_apple_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"apple_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,13 @@
|
|||||||
"when": 1770266674489,
|
"when": 1770266674489,
|
||||||
"tag": "0001_loose_kree",
|
"tag": "0001_loose_kree",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1770961427714,
|
||||||
|
"tag": "0002_outstanding_hiroim",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:keydown={handleKeydown} />
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
{#if isOpen}
|
{#if isOpen}
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
|
||||||
@@ -91,6 +91,25 @@
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="/auth/apple">
|
||||||
|
<input type="hidden" name="anonymousId" value={anonymousId} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-white text-black rounded-md hover:bg-gray-100 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/>
|
||||||
|
</svg>
|
||||||
|
Sign in with Apple
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="flex items-center my-4">
|
||||||
|
<div class="flex-1 border-t border-white/20"></div>
|
||||||
|
<span class="px-3 text-sm text-white/60">or</span>
|
||||||
|
<div class="flex-1 border-t border-white/20"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
action={mode === 'signin' ? '/auth/signin' : '/auth/signup'}
|
action={mode === 'signin' ? '/auth/signin' : '/auth/signup'}
|
||||||
|
|||||||
146
src/lib/server/apple-auth.ts
Normal file
146
src/lib/server/apple-auth.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { encodeBase64url } from '@oslojs/encoding';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
import { env as publicEnv } from '$env/dynamic/public';
|
||||||
|
|
||||||
|
const APPLE_AUTH_URL = 'https://appleid.apple.com/auth/authorize';
|
||||||
|
const APPLE_TOKEN_URL = 'https://appleid.apple.com/auth/token';
|
||||||
|
|
||||||
|
export function getAppleAuthUrl(state: string): string {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: env.APPLE_ID!,
|
||||||
|
redirect_uri: `${publicEnv.PUBLIC_SITE_URL}/auth/apple/callback`,
|
||||||
|
response_type: 'code',
|
||||||
|
response_mode: 'form_post',
|
||||||
|
scope: 'name email',
|
||||||
|
state
|
||||||
|
});
|
||||||
|
return `${APPLE_AUTH_URL}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateAppleClientSecret(): Promise<string> {
|
||||||
|
const header = { alg: 'ES256', kid: env.APPLE_KEY_ID! };
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const payload = {
|
||||||
|
iss: env.APPLE_TEAM_ID!,
|
||||||
|
iat: now,
|
||||||
|
exp: now + 86400 * 180,
|
||||||
|
aud: 'https://appleid.apple.com',
|
||||||
|
sub: env.APPLE_ID!
|
||||||
|
};
|
||||||
|
|
||||||
|
const encodedHeader = encodeBase64url(new TextEncoder().encode(JSON.stringify(header)));
|
||||||
|
const encodedPayload = encodeBase64url(new TextEncoder().encode(JSON.stringify(payload)));
|
||||||
|
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
||||||
|
|
||||||
|
// Import PEM private key
|
||||||
|
const pemBody = env.APPLE_PRIVATE_KEY!.replace(/-----BEGIN PRIVATE KEY-----/, '')
|
||||||
|
.replace(/-----END PRIVATE KEY-----/, '')
|
||||||
|
.replace(/\s/g, '');
|
||||||
|
const keyBuffer = Uint8Array.from(atob(pemBody), (c) => c.charCodeAt(0));
|
||||||
|
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'pkcs8',
|
||||||
|
keyBuffer,
|
||||||
|
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||||||
|
false,
|
||||||
|
['sign']
|
||||||
|
);
|
||||||
|
|
||||||
|
const signatureBuffer = await crypto.subtle.sign(
|
||||||
|
{ name: 'ECDSA', hash: 'SHA-256' },
|
||||||
|
key,
|
||||||
|
new TextEncoder().encode(signingInput)
|
||||||
|
);
|
||||||
|
|
||||||
|
const signature = new Uint8Array(signatureBuffer);
|
||||||
|
|
||||||
|
// crypto.subtle may return DER or raw (IEEE P1363) format depending on runtime
|
||||||
|
// Raw format is exactly 64 bytes (32-byte r + 32-byte s)
|
||||||
|
const rawSignature = signature.length === 64 ? signature : derToRaw(signature);
|
||||||
|
const encodedSignature = encodeBase64url(rawSignature);
|
||||||
|
|
||||||
|
return `${signingInput}.${encodedSignature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a DER-encoded ECDSA signature to raw r||s format (64 bytes for P-256)
|
||||||
|
*/
|
||||||
|
function derToRaw(der: Uint8Array): Uint8Array {
|
||||||
|
// DER structure: 0x30 [total-len] 0x02 [r-len] [r] 0x02 [s-len] [s]
|
||||||
|
let offset = 2; // skip 0x30 and total length
|
||||||
|
|
||||||
|
// Read r
|
||||||
|
if (der[offset] !== 0x02) throw new Error('Invalid DER signature');
|
||||||
|
offset++;
|
||||||
|
const rLen = der[offset];
|
||||||
|
offset++;
|
||||||
|
let r = der.slice(offset, offset + rLen);
|
||||||
|
offset += rLen;
|
||||||
|
|
||||||
|
// Read s
|
||||||
|
if (der[offset] !== 0x02) throw new Error('Invalid DER signature');
|
||||||
|
offset++;
|
||||||
|
const sLen = der[offset];
|
||||||
|
offset++;
|
||||||
|
let s = der.slice(offset, offset + sLen);
|
||||||
|
|
||||||
|
// Remove leading zero padding (DER uses it for positive sign)
|
||||||
|
if (r.length === 33 && r[0] === 0) r = r.slice(1);
|
||||||
|
if (s.length === 33 && s[0] === 0) s = s.slice(1);
|
||||||
|
|
||||||
|
// Pad to 32 bytes each
|
||||||
|
const raw = new Uint8Array(64);
|
||||||
|
raw.set(r, 32 - r.length);
|
||||||
|
raw.set(s, 64 - s.length);
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exchangeAppleCode(
|
||||||
|
code: string,
|
||||||
|
redirectUri: string
|
||||||
|
): Promise<{
|
||||||
|
access_token: string;
|
||||||
|
token_type: string;
|
||||||
|
expires_in: number;
|
||||||
|
refresh_token: string;
|
||||||
|
id_token: string;
|
||||||
|
}> {
|
||||||
|
const clientSecret = await generateAppleClientSecret();
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: env.APPLE_ID!,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
code,
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
redirect_uri: redirectUri
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(APPLE_TOKEN_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: params.toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.text();
|
||||||
|
throw new Error(`Apple token exchange failed: ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode Apple's id_token JWT payload without signature verification.
|
||||||
|
* Safe because the token is received directly from Apple's token endpoint over TLS.
|
||||||
|
*/
|
||||||
|
export function decodeAppleIdToken(idToken: string): {
|
||||||
|
sub: string;
|
||||||
|
email?: string;
|
||||||
|
email_verified?: string;
|
||||||
|
is_private_email?: string;
|
||||||
|
} {
|
||||||
|
const [, payloadB64] = idToken.split('.');
|
||||||
|
const padded = payloadB64 + '='.repeat((4 - (payloadB64.length % 4)) % 4);
|
||||||
|
const payload = JSON.parse(atob(padded.replace(/-/g, '+').replace(/_/g, '/')));
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
@@ -101,6 +101,7 @@ export async function createUser(anonymousId: string, email: string, passwordHas
|
|||||||
id: anonymousId, // Use anonymousId as the user ID to preserve stats
|
id: anonymousId, // Use anonymousId as the user ID to preserve stats
|
||||||
email,
|
email,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
|
appleId: null,
|
||||||
firstName: firstName || null,
|
firstName: firstName || null,
|
||||||
lastName: lastName || null,
|
lastName: lastName || null,
|
||||||
isPrivate: false
|
isPrivate: false
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export async function createUser(anonymousId: string, email: string, passwordHas
|
|||||||
id: anonymousId, // Use anonymousId as the user ID to preserve stats
|
id: anonymousId, // Use anonymousId as the user ID to preserve stats
|
||||||
email,
|
email,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
|
appleId: null,
|
||||||
firstName: firstName || null,
|
firstName: firstName || null,
|
||||||
lastName: lastName || null,
|
lastName: lastName || null,
|
||||||
isPrivate: false
|
isPrivate: false
|
||||||
@@ -113,3 +114,48 @@ export async function getUserByEmail(email: string) {
|
|||||||
const [user] = await db.select().from(table.user).where(eq(table.user.email, email));
|
const [user] = await db.select().from(table.user).where(eq(table.user.email, email));
|
||||||
return user || null;
|
return user || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getUserByAppleId(appleId: string) {
|
||||||
|
const [user] = await db.select().from(table.user).where(eq(table.user.appleId, appleId));
|
||||||
|
return user || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function migrateAnonymousStats(anonymousId: string | undefined, userId: string) {
|
||||||
|
if (!anonymousId || anonymousId === userId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { dailyCompletions } = await import('$lib/server/db/schema');
|
||||||
|
|
||||||
|
const anonCompletions = await db
|
||||||
|
.select()
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(eq(dailyCompletions.anonymousId, anonymousId));
|
||||||
|
|
||||||
|
const userCompletions = await db
|
||||||
|
.select()
|
||||||
|
.from(dailyCompletions)
|
||||||
|
.where(eq(dailyCompletions.anonymousId, userId));
|
||||||
|
|
||||||
|
const userDates = new Set(userCompletions.map((c) => c.date));
|
||||||
|
|
||||||
|
let migrated = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
for (const completion of anonCompletions) {
|
||||||
|
if (!userDates.has(completion.date)) {
|
||||||
|
await db
|
||||||
|
.update(dailyCompletions)
|
||||||
|
.set({ anonymousId: userId })
|
||||||
|
.where(eq(dailyCompletions.id, completion.id));
|
||||||
|
migrated++;
|
||||||
|
} else {
|
||||||
|
await db.delete(dailyCompletions).where(eq(dailyCompletions.id, completion.id));
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Migration complete: ${migrated} moved, ${skipped} duplicates removed`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error migrating anonymous stats:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const user = sqliteTable('user', {
|
|||||||
lastName: text('last_name'),
|
lastName: text('last_name'),
|
||||||
email: text('email').unique(),
|
email: text('email').unique(),
|
||||||
passwordHash: text('password_hash'),
|
passwordHash: text('password_hash'),
|
||||||
|
appleId: text('apple_id').unique(),
|
||||||
isPrivate: integer('is_private', { mode: 'boolean' }).default(false)
|
isPrivate: integer('is_private', { mode: 'boolean' }).default(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
27
src/routes/auth/apple/+page.server.ts
Normal file
27
src/routes/auth/apple/+page.server.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import type { Actions } from './$types';
|
||||||
|
import { getAppleAuthUrl } from '$lib/server/apple-auth';
|
||||||
|
import { encodeBase64url } from '@oslojs/encoding';
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
default: async ({ cookies, request }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const anonymousId = data.get('anonymousId')?.toString() || '';
|
||||||
|
|
||||||
|
// Generate CSRF state
|
||||||
|
const stateBytes = crypto.getRandomValues(new Uint8Array(16));
|
||||||
|
const state = encodeBase64url(stateBytes);
|
||||||
|
|
||||||
|
// Store state + anonymousId in a short-lived cookie
|
||||||
|
// sameSite 'none' + secure required because Apple POSTs cross-origin
|
||||||
|
cookies.set('apple_oauth_state', JSON.stringify({ state, anonymousId }), {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: 'none',
|
||||||
|
maxAge: 600
|
||||||
|
});
|
||||||
|
|
||||||
|
redirect(302, getAppleAuthUrl(state));
|
||||||
|
}
|
||||||
|
};
|
||||||
123
src/routes/auth/apple/callback/+server.ts
Normal file
123
src/routes/auth/apple/callback/+server.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { redirect, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { exchangeAppleCode, decodeAppleIdToken } from '$lib/server/apple-auth';
|
||||||
|
import { env as publicEnv } from '$env/dynamic/public';
|
||||||
|
import * as auth from '$lib/server/auth';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { user as userTable } from '$lib/server/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const code = formData.get('code')?.toString();
|
||||||
|
const state = formData.get('state')?.toString();
|
||||||
|
// Apple sends user info as JSON string on FIRST authorization only
|
||||||
|
const userInfoStr = formData.get('user')?.toString();
|
||||||
|
|
||||||
|
// Validate CSRF state
|
||||||
|
const storedRaw = cookies.get('apple_oauth_state');
|
||||||
|
if (!storedRaw || !state || !code) {
|
||||||
|
throw error(400, 'Invalid OAuth callback');
|
||||||
|
}
|
||||||
|
const stored = JSON.parse(storedRaw) as { state: string; anonymousId: string };
|
||||||
|
if (stored.state !== state) {
|
||||||
|
throw error(400, 'State mismatch');
|
||||||
|
}
|
||||||
|
cookies.delete('apple_oauth_state', { path: '/' });
|
||||||
|
|
||||||
|
// Exchange authorization code for tokens
|
||||||
|
const tokens = await exchangeAppleCode(code, `${publicEnv.PUBLIC_SITE_URL}/auth/apple/callback`);
|
||||||
|
const claims = decodeAppleIdToken(tokens.id_token);
|
||||||
|
const appleId = claims.sub;
|
||||||
|
|
||||||
|
// Parse user info (only present on first authorization)
|
||||||
|
let appleFirstName: string | undefined;
|
||||||
|
let appleLastName: string | undefined;
|
||||||
|
if (userInfoStr) {
|
||||||
|
try {
|
||||||
|
const userInfo = JSON.parse(userInfoStr);
|
||||||
|
appleFirstName = userInfo.name?.firstName;
|
||||||
|
appleLastName = userInfo.name?.lastName;
|
||||||
|
} catch {
|
||||||
|
/* ignore parse errors */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- User resolution ---
|
||||||
|
let userId: string;
|
||||||
|
|
||||||
|
// 1. Check if a user with this appleId already exists (returning user)
|
||||||
|
const existingAppleUser = await auth.getUserByAppleId(appleId);
|
||||||
|
|
||||||
|
if (existingAppleUser) {
|
||||||
|
userId = existingAppleUser.id;
|
||||||
|
await auth.migrateAnonymousStats(stored.anonymousId, userId);
|
||||||
|
} else if (claims.email) {
|
||||||
|
// 2. Check if email matches an existing email/password user
|
||||||
|
const existingEmailUser = await auth.getUserByEmail(claims.email);
|
||||||
|
if (existingEmailUser) {
|
||||||
|
// Link Apple account to existing user
|
||||||
|
await db.update(userTable).set({ appleId }).where(eq(userTable.id, existingEmailUser.id));
|
||||||
|
userId = existingEmailUser.id;
|
||||||
|
await auth.migrateAnonymousStats(stored.anonymousId, userId);
|
||||||
|
} else {
|
||||||
|
// 3. Brand new user — use anonymousId as user ID to preserve local stats
|
||||||
|
userId = stored.anonymousId || crypto.randomUUID();
|
||||||
|
try {
|
||||||
|
await db.insert(userTable).values({
|
||||||
|
id: userId,
|
||||||
|
email: claims.email,
|
||||||
|
passwordHash: null,
|
||||||
|
appleId,
|
||||||
|
firstName: appleFirstName || null,
|
||||||
|
lastName: appleLastName || null,
|
||||||
|
isPrivate: false
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
// Handle race condition: if appleId was inserted between our check and insert
|
||||||
|
if (e?.message?.includes('UNIQUE constraint')) {
|
||||||
|
const retryUser = await auth.getUserByAppleId(appleId);
|
||||||
|
if (retryUser) {
|
||||||
|
userId = retryUser.id;
|
||||||
|
} else {
|
||||||
|
throw error(500, 'Failed to create user');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No email from Apple — create account with appleId only
|
||||||
|
userId = stored.anonymousId || crypto.randomUUID();
|
||||||
|
try {
|
||||||
|
await db.insert(userTable).values({
|
||||||
|
id: userId,
|
||||||
|
email: null,
|
||||||
|
passwordHash: null,
|
||||||
|
appleId,
|
||||||
|
firstName: appleFirstName || null,
|
||||||
|
lastName: appleLastName || null,
|
||||||
|
isPrivate: false
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.message?.includes('UNIQUE constraint')) {
|
||||||
|
const retryUser = await auth.getUserByAppleId(appleId);
|
||||||
|
if (retryUser) {
|
||||||
|
userId = retryUser.id;
|
||||||
|
} else {
|
||||||
|
throw error(500, 'Failed to create user');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
const sessionToken = auth.generateSessionToken();
|
||||||
|
const session = await auth.createSession(sessionToken, userId);
|
||||||
|
auth.setSessionTokenCookie({ cookies } as any, sessionToken, session.expiresAt);
|
||||||
|
|
||||||
|
redirect(302, '/');
|
||||||
|
};
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
import { redirect, fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import type { Actions } from './$types';
|
import type { Actions } from './$types';
|
||||||
import * as auth from '$lib/server/auth';
|
import * as auth from '$lib/server/auth';
|
||||||
import { db } from '$lib/server/db';
|
|
||||||
import { dailyCompletions } from '$lib/server/db/schema';
|
|
||||||
import { eq, inArray } from 'drizzle-orm';
|
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
default: async ({ request, cookies }) => {
|
default: async ({ request, cookies }) => {
|
||||||
@@ -40,49 +37,7 @@ export const actions: Actions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Migrate anonymous stats if different anonymous ID
|
// Migrate anonymous stats if different anonymous ID
|
||||||
if (anonymousId && anonymousId !== user.id) {
|
await auth.migrateAnonymousStats(anonymousId, user.id);
|
||||||
try {
|
|
||||||
// Get completions for both the anonymous ID and the user ID
|
|
||||||
const anonCompletions = await db
|
|
||||||
.select()
|
|
||||||
.from(dailyCompletions)
|
|
||||||
.where(eq(dailyCompletions.anonymousId, anonymousId));
|
|
||||||
|
|
||||||
const userCompletions = await db
|
|
||||||
.select()
|
|
||||||
.from(dailyCompletions)
|
|
||||||
.where(eq(dailyCompletions.anonymousId, user.id));
|
|
||||||
|
|
||||||
// Create a set of dates the user already has completions for
|
|
||||||
const userDates = new Set(userCompletions.map(c => c.date));
|
|
||||||
|
|
||||||
let migrated = 0;
|
|
||||||
let skipped = 0;
|
|
||||||
|
|
||||||
// Migrate only non-conflicting completions
|
|
||||||
for (const completion of anonCompletions) {
|
|
||||||
if (!userDates.has(completion.date)) {
|
|
||||||
// No conflict - safe to migrate
|
|
||||||
await db
|
|
||||||
.update(dailyCompletions)
|
|
||||||
.set({ anonymousId: user.id })
|
|
||||||
.where(eq(dailyCompletions.id, completion.id));
|
|
||||||
migrated++;
|
|
||||||
} else {
|
|
||||||
// Conflict exists - delete the anonymous completion (keep user's existing one)
|
|
||||||
await db
|
|
||||||
.delete(dailyCompletions)
|
|
||||||
.where(eq(dailyCompletions.id, completion.id));
|
|
||||||
skipped++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Migration complete: ${migrated} moved, ${skipped} duplicates removed`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error migrating anonymous stats:', error);
|
|
||||||
// Don't fail the signin if stats migration fails
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create session
|
// Create session
|
||||||
const sessionToken = auth.generateSessionToken();
|
const sessionToken = auth.generateSessionToken();
|
||||||
|
|||||||
Reference in New Issue
Block a user