Add OAuth backend scaffold
This commit is contained in:
91
backend/README.md
Normal file
91
backend/README.md
Normal 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
2103
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
backend/package.json
Normal file
34
backend/package.json
Normal 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
33
backend/schema.sql
Normal 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
390
backend/src/server.ts
Normal 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
13
backend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user