Skip to content

Commit f319837

Browse files
author
Rajat
committed
Using oauth-server-kit for auth
1 parent e0abd34 commit f319837

32 files changed

Lines changed: 1638 additions & 1606 deletions

AGENTS.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,9 @@
1111
## Branch management
1212

1313
- Never push on your own. Leave it to the human.
14+
15+
## Testing Tips
16+
17+
- From browser based smoke testing, boot up the API and Web app using `pnpm dev:api` and `pnpm dev:web` respectively.
18+
- The following docker containers need to be running: `postgres`, `redis`, `mailpit` for full integration testing.
19+
- Prefer Chrome DevTools MCP for browser-based debugging, in the headful mode.

apps/api/docs/bounces-and-complaints.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1036,6 +1036,7 @@ feedback_invalid_credentials
10361036
feedback_test_failed
10371037
feedback_connection_retiring
10381038
esp_not_found
1039+
esp_not_active
10391040
recipient_suppressed
10401041
suppression_not_releasable
10411042
suppression_not_found

apps/api/docs/organizations.md

Lines changed: 26 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2160,31 +2160,32 @@ content, or unredacted webhook credentials.
21602160

21612161
## Failure semantics
21622162

2163-
| Status | Code | Meaning |
2164-
| ------ | ----------------------------------- | ---------------------------------------------------- |
2165-
| 400 | `invalid_delivery_source` | Malformed/conflicting selection |
2166-
| 401 | `invalid_organization_key` | Missing, invalid, expired, or revoked key |
2167-
| 403 | `organization_scope_required` | Key lacks required scope |
2168-
| 403 | `not_an_organization_member` | User has no membership |
2169-
| 403 | `team_esp_disabled` | Organization policy forbids team ESP mutation/use |
2170-
| 403 | `organization_delivery_disabled` | Team lacks an active organization grant |
2171-
| 403 | `team_sending_suspended` | Team cannot dispatch |
2172-
| 404 | `organization_not_found` | Missing/inaccessible organization |
2173-
| 404 | `esp_not_found` | Missing/foreign ESP in the relevant owner scope |
2174-
| 409 | `provisioning_conflict` | External ID exists with conflicting immutable fields |
2175-
| 409 | `delivery_source_in_use` | Unsafe source disable/delete |
2176-
| 409 | `invalid_lifecycle_transition` | ESP/grant transition is not permitted |
2177-
| 409 | `last_organization_owner` | Operation would leave no owner |
2178-
| 422 | `delivery_source_required` | No unambiguous default |
2179-
| 422 | `delivery_source_unavailable` | Team has no active shared or team-owned source |
2180-
| 422 | `organization_esp_unavailable` | Grant/ESP cannot currently send |
2181-
| 422 | `esp_verification_required` | ESP changed or has not passed its latest test |
2182-
| 422 | `provider_capability_required` | Provider lacks a required organization capability |
2183-
| 422 | `team_esp_not_configured` | Team source selected without ESP |
2184-
| 422 | `sender_unverified` | Selected sender is not usable |
2185-
| 429 | `organization_team_quota_exhausted` | Grant quota exhausted |
2186-
| 429 | `organization_quota_exhausted` | Aggregate organization quota exhausted |
2187-
| 503 | `user_onboarding_pending` | Default organization bootstrap has not completed |
2163+
| Status | Code | Meaning |
2164+
| ------ | ----------------------------------- | -------------------------------------------------------------- |
2165+
| 400 | `invalid_delivery_source` | Malformed/conflicting selection |
2166+
| 401 | `invalid_organization_key` | Missing, invalid, expired, or revoked key |
2167+
| 403 | `organization_scope_required` | Key lacks required scope |
2168+
| 403 | `not_an_organization_member` | User has no membership |
2169+
| 403 | `team_esp_disabled` | Organization policy forbids team ESP mutation/use |
2170+
| 403 | `organization_delivery_disabled` | Team lacks an active organization grant |
2171+
| 403 | `team_sending_suspended` | Team cannot dispatch |
2172+
| 404 | `organization_not_found` | Missing/inaccessible organization |
2173+
| 404 | `esp_not_found` | Missing/foreign ESP in the relevant owner scope |
2174+
| 409 | `provisioning_conflict` | External ID exists with conflicting immutable fields |
2175+
| 409 | `delivery_source_in_use` | Unsafe source disable/delete |
2176+
| 409 | `invalid_lifecycle_transition` | ESP/grant transition is not permitted |
2177+
| 409 | `last_organization_owner` | Operation would leave no owner |
2178+
| 422 | `delivery_source_required` | No unambiguous default |
2179+
| 422 | `delivery_source_unavailable` | Team has no active shared or team-owned source |
2180+
| 422 | `organization_esp_unavailable` | Grant/ESP cannot currently send |
2181+
| 422 | `esp_verification_required` | ESP changed or has not passed its latest test |
2182+
| 422 | `esp_not_active` | Team ESP exists but must be activated before default selection |
2183+
| 422 | `provider_capability_required` | Provider lacks a required organization capability |
2184+
| 422 | `team_esp_not_configured` | Team source selected without ESP |
2185+
| 422 | `sender_unverified` | Selected sender is not usable |
2186+
| 429 | `organization_team_quota_exhausted` | Grant quota exhausted |
2187+
| 429 | `organization_quota_exhausted` | Aggregate organization quota exhausted |
2188+
| 503 | `user_onboarding_pending` | Default organization bootstrap has not completed |
21882189

21892190
## REST, OpenAPI, MCP, and web requirements
21902191

apps/api/docs/replace-oauth-server-with-better-auth.md

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,21 @@
33
**Objective**
44
Replace SendLit’s custom OAuth2/auth implementation with Better Auth to support secure first-party web login, MCP OAuth, REST API authentication, and social login with Google plus Email OTP.
55

6+
**Implementation status**
7+
8+
Better Auth remains SendLit's product-local authorization server and owns its
9+
users, sessions, OAuth clients, consent, signing keys, and database adapter.
10+
The shared `@codelitdev/oauth-server-kit` now owns the reusable protocol layer:
11+
safe OAuth-provider defaults, hosted login/consent pages, session and bearer
12+
authentication results, and MCP/OAuth discovery metadata. SendLit continues to
13+
own organization keys, team keys, organization provisioning, team membership,
14+
multi-team OAuth selection, and authorization after authentication.
15+
16+
MCP Dynamic Client Registration (DCR) is intentionally enabled. Clients may
17+
register public OAuth clients without prior credentials, but registration is
18+
limited to SendLit's declared OAuth scopes; public clients must use PKCE.
19+
Unauthenticated registration is rate-limited to 20 requests per IP per minute.
20+
621
**Background**
722
SendLit currently has a custom OAuth2 implementation in `apps/api/src/oauth/*` and a BFF session/token flow in `apps/web/app/api/auth/*` and `apps/web/app/api/proxy/[...path]/route.ts`.
823

@@ -248,14 +263,20 @@ type AuthResult =
248263

249264
Resolution order:
250265

251-
1. API key if `x-sendlit-apikey` is present.
252-
2. OAuth bearer token if `Authorization: Bearer` is present.
253-
3. Better Auth session if cookies are present.
254-
4. Missing/unauthorized.
266+
1. An explicit organization key when `Authorization: Bearer sl_org_live_...`
267+
is present.
268+
2. An OAuth bearer token for every other explicit `Authorization: Bearer`
269+
credential.
270+
3. A team API key from `x-sendlit-apikey` or the legacy request body field.
271+
4. A Better Auth session if cookies are present.
272+
5. Missing/unauthorized.
255273

256274
Important:
257275

258276
- Invalid bearer token must not fall back to API key.
277+
- An organization key is SendLit product authentication, not an OAuth token;
278+
it is accepted only for its explicit `sl_org_live_...` prefix and is then
279+
authorized by its own SendLit scopes.
259280
- API key must remain team-scoped.
260281
- Session/OAuth account auth must still pass through team membership checks.
261282

@@ -266,8 +287,10 @@ Important:
266287
- Dashboard must not store bearer tokens in localStorage.
267288
- OAuth public clients must use PKCE.
268289
- Dynamic client registration must be intentionally configured:
269-
- unrestricted only if MCP requires it;
270-
- otherwise require authenticated/admin registration.
290+
- SendLit permits unauthenticated public-client registration for MCP;
291+
- registration is limited to the declared OAuth scopes;
292+
- public clients must use PKCE and registration is limited to 20 requests
293+
per IP per minute.
271294
- Access tokens must have clear audience/resource validation.
272295
- OAuth scopes must map to SendLit permissions.
273296
- Login and consent pages must set anti-clickjacking headers:

apps/api/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
},
2121
"dependencies": {
2222
"@better-auth/oauth-provider": "^1.6.23",
23+
"@codelitdev/oauth-server-kit": "0.1.0-alpha.0",
2324
"@modelcontextprotocol/sdk": "^1.29.0",
2425
"@sendlit/api-contract": "workspace:*",
2526
"@sendlit/email-blocks": "workspace:*",

apps/api/src/auth/better-auth.ts

Lines changed: 60 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,22 @@ import { emailOTP } from "better-auth/plugins/email-otp";
44
import { jwt } from "better-auth/plugins/jwt";
55
import { oauthProvider } from "@better-auth/oauth-provider";
66
import { oauthProviderResourceClient } from "@better-auth/oauth-provider/resource-client";
7+
import { createOAuthProviderOptions } from "@codelitdev/oauth-server-kit/better-auth";
8+
import type { HostedLoginMethod } from "@codelitdev/oauth-server-kit/express";
79
import { createTransport } from "nodemailer";
810
import { db } from "../db/client";
911
import * as schema from "../db/schema";
1012
import logger from "../services/log";
1113
import { ensureDefaultOrganization } from "../organization/queries";
12-
import { getOAuthTeamSelection, listTeamsForUser } from "../team/queries";
14+
import {
15+
createSendLitOAuthTeamSelectionHooks,
16+
oauthTeamSelectionAdapter,
17+
} from "./oauth-team-selection";
1318

1419
export const webClientUrl = process.env.WEB_CLIENT || "http://localhost:3000";
1520
const apiUrl = process.env.API_PUBLIC_URL || process.env.BETTER_AUTH_URL;
1621
const authBaseUrl = apiUrl || "http://localhost:5000";
17-
const authBasePath = "/api/auth";
22+
export const authBasePath = "/api/auth";
1823

1924
/** Parent domain shared by `apps/web` and `apps/api` in production (e.g.
2025
* `sendlit.example.com`, covering both `app.sendlit.example.com` and
@@ -93,28 +98,20 @@ async function sendOtpEmail(email: string, otp: string) {
9398
});
9499
}
95100

96-
/** The internal team id an OAuth end-user picked on `/oauth/select-team`
97-
* (`null` for a single-team user, which never sees that screen — see
98-
* `oauthPostLoginTeamSelections` in `db/schema.ts`). Shared by
99-
* `postLogin.shouldRedirect` and `postLogin.consentReferenceId` below so both
100-
* always agree on the same account/selection lookup. */
101-
async function resolveOAuthTeamSelection(
102-
userId: string,
103-
sessionId: string,
104-
): Promise<{ requiresSelection: boolean; selectedTeamId: string | null }> {
105-
const teams = await listTeamsForUser(userId);
106-
if (teams.length <= 1) {
107-
return { requiresSelection: false, selectedTeamId: null };
108-
}
101+
export { oauthTeamSelectionAdapter };
109102

110-
const selectedTeamId = await getOAuthTeamSelection(sessionId);
111-
const stillValid =
112-
selectedTeamId != null && teams.some((t) => t.id === selectedTeamId);
113-
return {
114-
requiresSelection: !stillValid,
115-
selectedTeamId: stillValid ? selectedTeamId : null,
116-
};
117-
}
103+
export const hostedLoginMethods: HostedLoginMethod[] = [
104+
{ type: "email-otp" },
105+
...(googleClientId && googleClientSecret
106+
? [
107+
{
108+
type: "social" as const,
109+
providerId: "google",
110+
label: "Continue with Google",
111+
},
112+
]
113+
: []),
114+
];
118115

119116
export const auth = betterAuth({
120117
appName: "SendLit",
@@ -180,57 +177,47 @@ export const auth = betterAuth({
180177
}),
181178
jwt(),
182179
oauthProvider({
183-
// Self-hosted by this API (see ./oauth-pages.ts), not the web
184-
// dashboard — so a new MCP/OAuth client can complete its first
185-
// authorization even where the web app isn't deployed at all.
186-
loginPage: `${authBaseUrl}/oauth/login`,
187-
consentPage: `${authBaseUrl}/oauth/consent`,
188-
allowDynamicClientRegistration: true,
189-
allowUnauthenticatedClientRegistration: true,
190-
scopes: [
191-
"openid",
192-
"profile",
193-
"email",
194-
"offline_access",
195-
"contacts:read",
196-
"contacts:write",
197-
"templates:read",
198-
"templates:write",
199-
"media:read",
200-
"media:write",
201-
"broadcasts:write",
202-
"sequences:read",
203-
"sequences:write",
204-
],
205-
validAudiences: validOAuthAudiences,
206-
// Interposed between login and consent for a multi-team account
207-
// (self-hosted at ./oauth-pages.ts, mirroring Notion's "select a
208-
// workspace" step) — an OAuth/MCP client has no standard way to
209-
// tell SendLit which team to scope its access to, so this is the
210-
// only point in the flow where that can be resolved.
211-
postLogin: {
180+
...createOAuthProviderOptions({
181+
loginPage: `${authBaseUrl}/oauth/login`,
182+
consentPage: `${authBaseUrl}/oauth/consent`,
183+
// MCP clients may register their own public OAuth client.
184+
// Registration is constrained to SendLit's supported scopes.
185+
allowDynamicClientRegistration: true,
186+
allowUnauthenticatedDynamicClientRegistration: true,
187+
scopes: [
188+
"openid",
189+
"profile",
190+
"email",
191+
"offline_access",
192+
"contacts:read",
193+
"contacts:write",
194+
"templates:read",
195+
"templates:write",
196+
"media:read",
197+
"media:write",
198+
"broadcasts:write",
199+
"sequences:read",
200+
"sequences:write",
201+
],
202+
validAudiences: validOAuthAudiences,
203+
clientRegistrationDefaultScopes: ["openid", "profile", "email"],
204+
clientRegistrationAllowedScopes: [
205+
"offline_access",
206+
"contacts:read",
207+
"contacts:write",
208+
"templates:read",
209+
"templates:write",
210+
"media:read",
211+
"media:write",
212+
"broadcasts:write",
213+
"sequences:read",
214+
"sequences:write",
215+
],
216+
}),
217+
...createSendLitOAuthTeamSelectionHooks({
212218
page: `${authBaseUrl}/oauth/select-team`,
213-
shouldRedirect: async ({ user, session }) => {
214-
const { requiresSelection } =
215-
await resolveOAuthTeamSelection(user.id, session.id);
216-
return requiresSelection;
217-
},
218-
consentReferenceId: async ({ user, session }) => {
219-
const { selectedTeamId } = await resolveOAuthTeamSelection(
220-
user.id,
221-
session.id,
222-
);
223-
return selectedTeamId ?? undefined;
224-
},
225-
},
226-
// Carries the picked team (the `referenceId` above) onto the
227-
// minted access token so `resolve-auth.ts` can scope a request
228-
// without relying on `X-Sendlit-Team-Id`, which generic OAuth
229-
// clients never send. Single-team accounts have no referenceId
230-
// and fall back to `require-team.ts`'s existing auto-select.
231-
customAccessTokenClaims: async ({ referenceId }) => {
232-
return referenceId ? { team_id: referenceId } : {};
233-
},
219+
adapter: oauthTeamSelectionAdapter,
220+
}),
234221
}),
235222
],
236223
});

apps/api/src/auth/middleware.ts

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
import { NextFunction, Response } from "express";
2-
import { AuthResult, resolveAuth, sendAuthError } from "./resolve-auth";
1+
import type { NextFunction, Request, Response } from "express";
2+
import {
3+
type AuthInput,
4+
type AuthResult,
5+
resolveAuth,
6+
sendAuthError,
7+
} from "./resolve-auth";
38
import { mcpProtectedResourceMetadataUrl } from "./better-auth";
49

5-
type AuthResolver = (input: {
6-
authorization?: unknown;
7-
apiKeyHeader?: unknown;
8-
bodyApiKey?: unknown;
9-
headers?: Record<string, string | string[] | undefined>;
10-
}) => Promise<AuthResult>;
10+
type AuthResolver = (input: AuthInput) => Promise<AuthResult>;
1111

1212
type AuthMiddlewareMode = "rest" | "mcp";
1313

@@ -53,22 +53,29 @@ export function createAuthMiddleware(
5353
resourceMetadataUrl?: string,
5454
) {
5555
return async function authMiddleware(
56-
req: any,
56+
req: Request,
5757
res: Response,
5858
next: NextFunction,
5959
): Promise<void> {
60-
const auth = await authResolver({
61-
authorization: req.headers.authorization,
62-
apiKeyHeader: req.headers["x-sendlit-apikey"],
63-
bodyApiKey: req.body?.apikey,
64-
headers: req.headers,
65-
});
60+
try {
61+
const auth = await authResolver({
62+
authorization: req.headers.authorization,
63+
apiKeyHeader: req.headers["x-sendlit-apikey"],
64+
bodyApiKey: req.body?.apikey,
65+
headers: req.headers,
66+
});
6667

67-
if (sendAuthError(res, auth, resourceMetadataUrl)) return;
68-
if (auth.status !== "authenticated") return;
68+
if (sendAuthError(res, auth, resourceMetadataUrl)) return;
69+
if (auth.status !== "authenticated") return;
6970

70-
applyAuthToRequest(req, auth, mode);
71-
next();
71+
applyAuthToRequest(req, auth, mode);
72+
if (auth.kind === "oauth" || auth.kind === "session") {
73+
req.auth = auth.identity;
74+
}
75+
next();
76+
} catch (error) {
77+
next(error);
78+
}
7279
};
7380
}
7481

0 commit comments

Comments
 (0)