Skip to content

Commit 1a7c130

Browse files
authored
Merge branch 'main' into feature/expiry-countdown
2 parents ec30670 + 33f4dd2 commit 1a7c130

245 files changed

Lines changed: 49805 additions & 1323 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,5 +102,15 @@ JWT_AUDIENCE=delego-clients
102102
# Helps tolerate minor clock drift between distributed services. Default 5, max 300.
103103
JWT_CLOCK_TOLERANCE_SECONDS=5
104104

105+
# --- OAuth2 Providers ---
106+
# Google OAuth2 (https://console.cloud.google.com/apis/credentials)
107+
GOOGLE_CLIENT_ID=
108+
GOOGLE_CLIENT_SECRET=
109+
# GitHub OAuth (https://github.com/settings/developers)
110+
GITHUB_CLIENT_ID=
111+
GITHUB_CLIENT_SECRET=
112+
# Redirect URI for OAuth callbacks (must match provider configuration)
113+
OAUTH_REDIRECT_URI=http://localhost:3000/api/v1/auth/oauth/callback
114+
105115
# --- Analytics ---
106116
ANALYTICS_ENABLED=true

apps/backend/gateway/auth/README.md

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,70 @@
22

33
Authentication and authorization modules for the API gateway.
44

5+
## Features
6+
7+
- Email/password authentication with JWT access + refresh tokens
8+
- OAuth2 login via Google and GitHub providers
9+
- Refresh token rotation with reuse detection
10+
- Audit event logging for all auth actions
11+
12+
## OAuth2 Integration
13+
14+
### Supported Providers
15+
16+
| Provider | Authorization URL | Token URL | User Info URL |
17+
|----------|-------------------|-----------|---------------|
18+
| Google | `https://accounts.google.com/o/oauth2/v2/auth` | `https://oauth2.googleapis.com/token` | `https://www.googleapis.com/oauth2/v3/userinfo` |
19+
| GitHub | `https://github.com/login/oauth/authorize` | `https://github.com/login/oauth/access_token` | `https://api.github.com/user` |
20+
21+
### Environment Variables
22+
23+
```bash
24+
# Google OAuth2
25+
GOOGLE_CLIENT_ID=your-google-client-id
26+
GOOGLE_CLIENT_SECRET=your-google-client-secret
27+
28+
# GitHub OAuth
29+
GITHUB_CLIENT_ID=your-github-client-id
30+
GITHUB_CLIENT_SECRET=your-github-client-secret
31+
32+
# Redirect URI (must match provider configuration)
33+
OAUTH_REDIRECT_URI=http://localhost:3000/api/v1/auth/oauth/callback
34+
```
35+
36+
### Endpoints
37+
38+
- `GET /api/v1/auth/oauth/authorize?provider={google|github}&redirect_uri={uri}` - Returns authorization URL
39+
- `POST /api/v1/auth/oauth/callback` - Exchanges code for tokens and authenticates user
40+
41+
### Account Linking
42+
43+
When a user authenticates via OAuth:
44+
1. If an OAuth account link exists for the provider/userId, the existing user is authenticated
45+
2. If no link exists but a user with the same email is found, the OAuth account is linked to that user
46+
3. If neither exists, a new user is created (without a password) and the OAuth account is linked
47+
48+
### Database Migration
49+
50+
Run migration `013_oauth_providers.sql` to create the `oauth_accounts` table:
51+
52+
```sql
53+
CREATE TABLE IF NOT EXISTS oauth_accounts (
54+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
55+
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
56+
provider VARCHAR(32) NOT NULL,
57+
provider_user_id VARCHAR(255) NOT NULL,
58+
email VARCHAR(255),
59+
display_name VARCHAR(255),
60+
avatar_url TEXT,
61+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
62+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
63+
UNIQUE(provider, provider_user_id)
64+
);
65+
```
66+
567
## Planned
668

7-
- JWT validation
869
- Wallet signature verification (Stellar)
970
- Rate limiting per user
1071
- API key support for merchant integrations
11-
12-
<!-- TODO: Implement auth providers -->

apps/backend/gateway/routes/api-v1.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export function getBodyLimitConfig(): BodyLimitConfig {
3737
}
3838

3939
/** Parse a human-readable JSON body limit string into bytes. */
40+
4041
export function parseJsonLimit(limit: string): number {
4142
const trimmed = limit.trim().toLowerCase();
4243
const match = trimmed.match(/^(\d+(?:\.\d+)?)(b|kb|mb|gb)?$/);

apps/backend/gateway/routes/auth.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { IncomingMessage, ServerResponse } from "node:http";
22
import { generateId, json } from "@delego/utils";
33
import * as authService from "../src/auth/authService.js";
4+
import * as oauthService from "../src/auth/oauthService.js";
45
import {
56
publishAuthAuditEvent,
67
AUTH_AUDIT_ACTIONS,
@@ -9,6 +10,7 @@ import {
910
validateSchema,
1011
RegisterSchema,
1112
LoginSchema,
13+
OAuthCallbackSchema,
1214
} from "../src/validation.js";
1315
import {
1416
readJsonBody,
@@ -27,6 +29,9 @@ export const authDependencies = {
2729
loginUser: authService.loginUser,
2830
refreshAccessToken: authService.refreshAccessToken,
2931
logoutUser: authService.logoutUser,
32+
handleOAuthCallback: oauthService.handleOAuthCallback,
33+
buildAuthorizationUrl: oauthService.buildAuthorizationUrl,
34+
validateProvider: oauthService.validateProvider,
3035
};
3136

3237
function resolveRequestId(req: IncomingMessage): string {
@@ -271,3 +276,86 @@ export async function logoutHandler(
271276
error: null,
272277
});
273278
}
279+
280+
export async function oauthCallbackHandler(
281+
req: IncomingMessage,
282+
res: ServerResponse,
283+
): Promise<void> {
284+
const requestId = resolveRequestId(req);
285+
286+
try {
287+
const body = await readJsonBody(req);
288+
const validation = validateSchema(OAuthCallbackSchema, body);
289+
if (!validation.valid) {
290+
publishAuthAuditEvent({
291+
action: AUTH_AUDIT_ACTIONS.OAUTH_LOGIN,
292+
success: false,
293+
requestId,
294+
});
295+
badRequest(res, "Invalid request body", req, validation.errors);
296+
return;
297+
}
298+
299+
const { provider, code, state } = body;
300+
const redirectUri = process.env.OAUTH_REDIRECT_URI ?? "";
301+
302+
const result = await authDependencies.handleOAuthCallback(provider, code, redirectUri);
303+
304+
publishAuthAuditEvent({
305+
action: result.isNewUser ? AUTH_AUDIT_ACTIONS.OAUTH_REGISTER : AUTH_AUDIT_ACTIONS.OAUTH_LOGIN,
306+
success: true,
307+
requestId,
308+
userId: result.user.id,
309+
email: result.user.email,
310+
});
311+
312+
setRefreshTokenCookie(res, result.refreshToken);
313+
json(res, 200, {
314+
data: {
315+
user: result.user,
316+
accessToken: result.accessToken,
317+
expiresIn: result.expiresIn,
318+
isNewUser: result.isNewUser,
319+
},
320+
error: null,
321+
});
322+
} catch (err: any) {
323+
publishAuthAuditEvent({
324+
action: AUTH_AUDIT_ACTIONS.OAUTH_LOGIN,
325+
success: false,
326+
requestId,
327+
});
328+
if (err instanceof InvalidJsonError || err instanceof BodyTooLargeError) {
329+
badRequest(res, err.message, req);
330+
} else {
331+
sendApiError(res, 400, "OAUTH_ERROR", err.message, req);
332+
}
333+
}
334+
}
335+
336+
export async function oauthAuthorizeHandler(
337+
req: IncomingMessage,
338+
res: ServerResponse,
339+
): Promise<void> {
340+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
341+
const provider = url.searchParams.get("provider");
342+
const redirectUri = url.searchParams.get("redirect_uri") ?? process.env.OAUTH_REDIRECT_URI ?? "";
343+
344+
if (!provider) {
345+
sendApiError(res, 400, "VALIDATION_ERROR", "provider query parameter is required", req);
346+
return;
347+
}
348+
349+
try {
350+
const validatedProvider = authDependencies.validateProvider(provider);
351+
const state = generateId();
352+
const authorizationUrl = authDependencies.buildAuthorizationUrl(validatedProvider, redirectUri, state);
353+
354+
json(res, 200, {
355+
data: { authorizationUrl, state },
356+
error: null,
357+
});
358+
} catch (err: any) {
359+
sendApiError(res, 400, "INVALID_PROVIDER", err.message, req);
360+
}
361+
}

apps/backend/gateway/routes/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {
77
loginHandler,
88
refreshHandler,
99
logoutHandler,
10+
oauthCallbackHandler,
11+
oauthAuthorizeHandler,
1012
} from "./auth.js";
1113
import {
1214
createDelegationHandler,
@@ -28,6 +30,8 @@ export function registerRoutes(): Route[] {
2830
route("POST", "/api/v1/auth/login", loginHandler),
2931
route("POST", "/api/v1/auth/refresh", refreshHandler),
3032
route("POST", "/api/v1/auth/logout", logoutHandler),
33+
route("GET", "/api/v1/auth/oauth/authorize", oauthAuthorizeHandler),
34+
route("POST", "/api/v1/auth/oauth/callback", oauthCallbackHandler),
3135
route("POST", "/api/v1/delegations", createDelegationHandler),
3236
route("GET", "/api/v1/delegations", listDelegationsHandler),
3337
route("GET", "/api/v1/delegations/:id", getDelegationHandler),

apps/backend/gateway/src/auth/authAuditEvent.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ export const AUTH_AUDIT_ACTIONS = {
2323
REGISTER: "register",
2424
LOGOUT: "logout",
2525
REFRESH: "refresh",
26+
OAUTH_LOGIN: "oauth_login",
27+
OAUTH_REGISTER: "oauth_register",
2628
} as const;
2729

2830
/** In-memory sink for auth audit events — swap for Redis publish in production. */

0 commit comments

Comments
 (0)