Add OAuth backend scaffold

This commit is contained in:
MrDiderot
2026-07-22 22:54:03 +02:00
parent 77d93c4f8d
commit 39a0e35fd2
14 changed files with 2971 additions and 37 deletions

91
backend/README.md Normal file
View File

@@ -0,0 +1,91 @@
# FeelAloud Backend
Backend for real accounts:
- E-Mail and password registration/login
- Sign in with Apple ID-token verification
- Google ID-token verification
- JWT session tokens for the app
- Premium entitlement storage prepared for App Store Server verification
## Setup
1. Install dependencies:
```bash
npm install
```
2. Create PostgreSQL database and enable UUID generation:
```sql
create extension if not exists pgcrypto;
```
3. Copy config:
```bash
cp .env.example .env
```
4. Fill `.env`:
- `JWT_SECRET`: at least 32 random characters
- `APPLE_CLIENT_ID`: app bundle id, currently `de.feelaloud`
- `GOOGLE_CLIENT_IDS`: Google OAuth iOS/Web client IDs, comma-separated
5. Initialize DB:
```bash
npm run db:init
```
6. Start local API:
```bash
npm run dev
```
## Endpoints
- `GET /health`
- `POST /auth/email/register`
- `POST /auth/email/login`
- `POST /auth/email/change-password`
- `POST /auth/apple`
- `POST /auth/google`
- `GET /me`
- `GET /premium/status`
- `POST /premium/app-store/transaction`
## iOS Configuration
In the app, set the backend URL in Settings -> Profil once that UI is wired, or set `authBackendURL` in `UserDefaults` during testing:
```swift
UserDefaults.standard.set("http://localhost:8080", forKey: "authBackendURL")
```
For local device testing use your Mac's LAN IP instead of `localhost`.
## Required Apple/Google Console Setup
Apple:
- Paid Apple Developer account
- App capability: Sign in with Apple
- Bundle ID: `de.feelaloud`
- For server/web flows: Services ID and private key if you later exchange authorization codes server-side
Google:
- Google Cloud project
- OAuth consent screen
- iOS OAuth client for bundle ID `de.feelaloud`
- Add the resulting client ID to `GOOGLE_CLIENT_IDS`
Premium:
- Digital premium features must stay on StoreKit/In-App Purchase.
- PayPal is not the payment rail for iOS digital premium unlocks.
- Server-side transaction verification can be completed with App Store Server API once App Store Connect keys are available.

2103
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
backend/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "feelaloud-backend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/server.js",
"db:init": "psql \"$DATABASE_URL\" -f schema.sql"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"google-auth-library": "^10.9.0",
"helmet": "^8.0.0",
"jose": "^5.9.6",
"jsonwebtoken": "^9.0.2",
"pg": "^8.13.1",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.7",
"@types/node": "^22.10.2",
"@types/pg": "^8.11.10",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}

33
backend/schema.sql Normal file
View File

@@ -0,0 +1,33 @@
create extension if not exists pgcrypto;
create table if not exists users (
id uuid primary key default gen_random_uuid(),
email text unique,
display_name text not null default '',
avatar_url text,
password_hash text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table if not exists identities (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references users(id) on delete cascade,
provider text not null check (provider in ('apple', 'google', 'email')),
provider_subject text not null,
email text,
created_at timestamptz not null default now(),
unique(provider, provider_subject)
);
create table if not exists premium_entitlements (
user_id uuid primary key references users(id) on delete cascade,
source text not null default 'app_store',
product_id text not null,
active boolean not null default false,
expires_at timestamptz,
updated_at timestamptz not null default now()
);
create index if not exists identities_user_id_idx on identities(user_id);
create index if not exists premium_entitlements_active_idx on premium_entitlements(active);

390
backend/src/server.ts Normal file
View File

@@ -0,0 +1,390 @@
import 'dotenv/config';
import bcrypt from 'bcryptjs';
import cors from 'cors';
import express from 'express';
import { OAuth2Client } from 'google-auth-library';
import helmet from 'helmet';
import jwt from 'jsonwebtoken';
import { createRemoteJWKSet, jwtVerify } from 'jose';
import pg from 'pg';
import { z } from 'zod';
const { Pool } = pg;
const config = {
port: Number(process.env.PORT ?? 8080),
databaseURL: required('DATABASE_URL'),
jwtSecret: required('JWT_SECRET'),
jwtExpiresIn: (process.env.JWT_EXPIRES_IN ?? '30d') as jwt.SignOptions['expiresIn'],
appleClientID: required('APPLE_CLIENT_ID'),
googleClientIDs: (process.env.GOOGLE_CLIENT_IDS ?? '')
.split(',')
.map((value) => value.trim())
.filter(Boolean),
corsOrigin: process.env.CORS_ORIGIN ?? '*'
};
const pool = new Pool({ connectionString: config.databaseURL });
const googleClient = new OAuth2Client();
const appleJWKS = createRemoteJWKSet(new URL('https://appleid.apple.com/auth/keys'));
const app = express();
app.use(helmet());
app.use(cors({ origin: config.corsOrigin === '*' ? true : config.corsOrigin }));
app.use(express.json({ limit: '1mb' }));
app.get('/health', (_request, response) => {
response.json({ ok: true });
});
app.post('/auth/email/register', asyncHandler(async (request, response) => {
const input = emailAuthSchema.extend({
displayName: z.string().trim().max(120).optional()
}).parse(request.body);
const passwordHash = await bcrypt.hash(input.password, 12);
const client = await pool.connect();
try {
await client.query('begin');
const user = await createEmailUser(client, input.email, passwordHash, input.displayName ?? '');
await client.query('commit');
response.json(await sessionResponse(user.id));
} catch (error) {
await client.query('rollback');
if (isUniqueViolation(error)) {
response.status(409).json({ error: 'email_already_registered' });
return;
}
throw error;
} finally {
client.release();
}
}));
app.post('/auth/email/login', asyncHandler(async (request, response) => {
const input = emailAuthSchema.parse(request.body);
const { rows } = await pool.query<UserRow>(
'select * from users where lower(email) = lower($1) and password_hash is not null limit 1',
[input.email]
);
const user = rows[0];
if (!user || !user.password_hash || !(await bcrypt.compare(input.password, user.password_hash))) {
response.status(401).json({ error: 'invalid_credentials' });
return;
}
response.json(await sessionResponse(user.id));
}));
app.post('/auth/email/change-password', requireAuth, asyncHandler(async (request, response) => {
const input = z.object({
currentPassword: z.string().min(8),
newPassword: z.string().min(10).max(200)
}).parse(request.body);
const { rows } = await pool.query<UserRow>('select * from users where id = $1 limit 1', [request.userID]);
const user = rows[0];
if (!user?.password_hash || !(await bcrypt.compare(input.currentPassword, user.password_hash))) {
response.status(401).json({ error: 'invalid_current_password' });
return;
}
const passwordHash = await bcrypt.hash(input.newPassword, 12);
await pool.query(
'update users set password_hash = $1, updated_at = now() where id = $2',
[passwordHash, request.userID]
);
response.json({ ok: true });
}));
app.post('/auth/apple', asyncHandler(async (request, response) => {
const input = appleAuthSchema.parse(request.body);
const payload = await verifyAppleIdentityToken(input.identityToken);
const subject = String(payload.sub ?? '');
if (!subject) {
response.status(401).json({ error: 'invalid_apple_subject' });
return;
}
const email = typeof payload.email === 'string' ? payload.email : input.email;
const user = await upsertOAuthUser({
provider: 'apple',
providerSubject: subject,
email,
displayName: input.displayName ?? ''
});
response.json(await sessionResponse(user.id));
}));
app.post('/auth/google', asyncHandler(async (request, response) => {
const input = googleAuthSchema.parse(request.body);
if (config.googleClientIDs.length === 0) {
response.status(503).json({ error: 'google_not_configured' });
return;
}
const ticket = await googleClient.verifyIdToken({
idToken: input.idToken,
audience: config.googleClientIDs
});
const payload = ticket.getPayload();
const subject = payload?.sub;
if (!subject) {
response.status(401).json({ error: 'invalid_google_subject' });
return;
}
const user = await upsertOAuthUser({
provider: 'google',
providerSubject: subject,
email: payload.email,
displayName: payload.name ?? ''
});
response.json(await sessionResponse(user.id));
}));
app.get('/me', requireAuth, asyncHandler(async (request, response) => {
response.json({ user: await publicUser(authenticatedUserID(request)) });
}));
app.get('/premium/status', requireAuth, asyncHandler(async (request, response) => {
const { rows } = await pool.query<PremiumRow>(
'select * from premium_entitlements where user_id = $1 limit 1',
[authenticatedUserID(request)]
);
response.json({ premium: entitlementIsActive(rows[0]), entitlement: rows[0] ?? null });
}));
app.post('/premium/app-store/transaction', requireAuth, asyncHandler(async (_request, response) => {
response.status(501).json({
error: 'app_store_server_verification_not_configured',
message: 'StoreKit on-device verification is active in the app. Server-side App Store verification needs App Store Connect API keys and transaction JWS forwarding.'
});
}));
app.use((error: unknown, _request: express.Request, response: express.Response, _next: express.NextFunction) => {
if (error instanceof z.ZodError) {
response.status(400).json({ error: 'validation_failed', details: error.flatten() });
return;
}
console.error(error);
response.status(500).json({ error: 'internal_server_error' });
});
app.listen(config.port, () => {
console.log(`FeelAloud backend listening on :${config.port}`);
});
function required(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required environment variable ${name}`);
}
return value;
}
const emailAuthSchema = z.object({
email: z.string().trim().email().max(320),
password: z.string().min(8).max(200)
});
const appleAuthSchema = z.object({
identityToken: z.string().min(20),
authorizationCode: z.string().optional(),
email: z.string().email().optional(),
displayName: z.string().trim().max(120).optional()
});
const googleAuthSchema = z.object({
idToken: z.string().min(20)
});
async function verifyAppleIdentityToken(identityToken: string) {
const { payload } = await jwtVerify(identityToken, appleJWKS, {
issuer: 'https://appleid.apple.com',
audience: config.appleClientID
});
return payload;
}
async function createEmailUser(client: pg.PoolClient, email: string, passwordHash: string, displayName: string): Promise<UserRow> {
const insertedUser = await client.query<UserRow>(
`insert into users (email, display_name, password_hash)
values ($1, $2, $3)
returning *`,
[email, displayName, passwordHash]
);
const user = insertedUser.rows[0];
await client.query(
`insert into identities (user_id, provider, provider_subject, email)
values ($1, 'email', $2, $2)`,
[user.id, email]
);
return user;
}
async function upsertOAuthUser(input: OAuthUserInput): Promise<UserRow> {
const client = await pool.connect();
try {
await client.query('begin');
const identity = await client.query<{ user_id: string }>(
'select user_id from identities where provider = $1 and provider_subject = $2 limit 1',
[input.provider, input.providerSubject]
);
if (identity.rows[0]) {
const existingUser = await client.query<UserRow>(
'select * from users where id = $1 limit 1',
[identity.rows[0].user_id]
);
const user = existingUser.rows[0];
if (!user) {
throw new Error('User not found for identity');
}
await client.query('commit');
return user;
}
const existingByEmail = input.email
? await client.query<UserRow>('select * from users where lower(email) = lower($1) limit 1', [input.email])
: { rows: [] as UserRow[] };
const user = existingByEmail.rows[0] ?? (await client.query<UserRow>(
`insert into users (email, display_name)
values ($1, $2)
returning *`,
[input.email ?? null, input.displayName]
)).rows[0];
if (input.displayName && !user.display_name) {
await client.query('update users set display_name = $1, updated_at = now() where id = $2', [input.displayName, user.id]);
user.display_name = input.displayName;
}
await client.query(
`insert into identities (user_id, provider, provider_subject, email)
values ($1, $2, $3, $4)`,
[user.id, input.provider, input.providerSubject, input.email ?? null]
);
await client.query('commit');
return user;
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
}
}
async function sessionResponse(userID: string) {
return {
token: jwt.sign({ sub: userID }, config.jwtSecret, { expiresIn: config.jwtExpiresIn }),
user: await publicUser(userID)
};
}
async function publicUser(userID: string): Promise<PublicUser> {
const { rows } = await pool.query<UserRow>('select * from users where id = $1 limit 1', [userID]);
const user = rows[0];
if (!user) {
throw new Error('User not found');
}
return {
id: user.id,
email: user.email,
displayName: user.display_name,
avatarURL: user.avatar_url
};
}
function entitlementIsActive(row?: PremiumRow): boolean {
if (!row || !row.active) {
return false;
}
if (!row.expires_at) {
return true;
}
return new Date(row.expires_at).getTime() > Date.now();
}
function requireAuth(request: express.Request, response: express.Response, next: express.NextFunction) {
const header = request.header('authorization') ?? '';
const token = header.startsWith('Bearer ') ? header.slice(7) : '';
if (!token) {
response.status(401).json({ error: 'missing_token' });
return;
}
try {
const payload = jwt.verify(token, config.jwtSecret);
if (typeof payload !== 'object' || typeof payload.sub !== 'string') {
response.status(401).json({ error: 'invalid_token' });
return;
}
request.userID = payload.sub;
next();
} catch {
response.status(401).json({ error: 'invalid_token' });
}
}
function authenticatedUserID(request: express.Request): string {
if (!request.userID) {
throw new Error('Authenticated route reached without user id');
}
return request.userID;
}
function asyncHandler(handler: express.RequestHandler): express.RequestHandler {
return (request, response, next) => {
Promise.resolve(handler(request, response, next)).catch(next);
};
}
function isUniqueViolation(error: unknown): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === '23505';
}
type Provider = 'apple' | 'google' | 'email';
interface OAuthUserInput {
provider: Provider;
providerSubject: string;
email?: string;
displayName: string;
}
interface UserRow {
id: string;
email: string | null;
display_name: string;
avatar_url: string | null;
password_hash: string | null;
}
interface PublicUser {
id: string;
email: string | null;
displayName: string;
avatarURL: string | null;
}
interface PremiumRow {
user_id: string;
source: string;
product_id: string;
active: boolean;
expires_at: string | null;
}
declare global {
namespace Express {
interface Request {
userID?: string;
}
}
}

13
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"outDir": "dist"
},
"include": ["src/**/*.ts"]
}