Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ Create Google OAuth credentials in the [Google Cloud Console](https://console.cl
- use a Google OAuth **Web application** client for the backend start/callback flow
- register the callback URL from the deployed stack output `GoogleOAuthCallbackUrl`
- expect the app to request identity scopes (`openid`, `email`) in addition to the minimal Gmail scopes needed for inbox processing
- for SPA clients, call `/auth/google/start` with `Authorization: Bearer <token>`, read the returned `authorizationUrl`, then redirect the browser to that URL
- the SAM HttpApi CORS config currently allows `http://localhost:3000` for local SPA development

If you still need a local one-off token for manual testing, you can use the legacy helper:

Expand Down Expand Up @@ -100,7 +102,7 @@ SAM now provisions:
- scaffolded HttpApi routes and Lambda functions for `/auth/google/start`, `/auth/google/callback`, and `/auth/google/disconnect`
- stack outputs for `OAuthHttpApiBaseUrl` and `GoogleOAuthCallbackUrl`

The OAuth start and callback handlers are implemented in the stacked MVP work, while the disconnect handler remains a placeholder until `LEY-7`.
The OAuth start handler returns JSON for SPA callers, and the callback route stays public so Google can complete the redirect. The disconnect handler remains a placeholder until `LEY-7`.

### 4. Verify

Expand Down
8 changes: 5 additions & 3 deletions docs/gmail-connection-contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
- `GMAIL_CONNECTION_SUCCESS_REDIRECT_URL`: future OAuth callback success redirect target.
- `GMAIL_CONNECTION_FAILURE_REDIRECT_URL`: future OAuth callback failure redirect target.
- `/auth/google/start`, `/auth/google/callback`, and `/auth/google/disconnect` are wired in SAM as HttpApi routes; disconnect remains placeholder-level until `LEY-7`.
- `/auth/google/start` returns JSON with an `authorizationUrl` for SPA callers to redirect the browser to Google consent.

## App secrets vs user connection data

Expand All @@ -22,9 +23,10 @@

## Request and auth boundary

- All three OAuth routes (`/auth/google/start`, `/auth/google/callback`, `/auth/google/disconnect`) are protected by an Auth0 JWT authorizer configured on the API Gateway HTTP API.
- API Gateway verifies the JWT signature (via Auth0's JWKS endpoint), expiry, issuer, and audience before any Lambda is invoked. Requests without a valid `Authorization: Bearer <token>` header receive a `401` immediately, before Lambda is ever called.
- Inside Lambda, `JwtAuthenticatedAppUserProvider` reads `userId` from `event.requestContext.authorizer.jwt.claims.sub` — the verified subject claim injected by API Gateway. This value is always a stable Auth0 user identifier (e.g. `google-oauth2|1234567890` for Google-federated users).
- `/auth/google/start` and `/auth/google/disconnect` are protected by an Auth0 JWT authorizer configured on the API Gateway HTTP API.
- API Gateway verifies the JWT signature (via Auth0's JWKS endpoint), expiry, issuer, and audience before protected Lambdas are invoked. Requests without a valid `Authorization: Bearer <token>` header receive a `401` immediately, before Lambda is ever called.
- Inside protected Lambdas, `JwtAuthenticatedAppUserProvider` reads `userId` from `event.requestContext.authorizer.jwt.claims.sub` — the verified subject claim injected by API Gateway. This value is always a stable Auth0 user identifier (e.g. `google-oauth2|1234567890` for Google-federated users).
- `/auth/google/callback` is intentionally public so Google can complete the redirect back to the backend without an app Bearer token.
- The `IAuthenticatedAppUserProvider` interface is preserved so the auth provider can vary by environment (e.g. a test double in unit tests).
- OAuth state records are consumed on callback so one-time `state` values cannot be reused.
- OAuth callback code calls `GmailConnectionRepository.upsertPrimary()` to store the encrypted refresh token scoped to the `userId`.
Expand Down
6 changes: 3 additions & 3 deletions src/handlers/googleOAuthCallback.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { APIGatewayProxyEventV2WithJWTAuthorizer, APIGatewayProxyStructuredResultV2 } from "aws-lambda";
import { APIGatewayProxyEventV2, APIGatewayProxyStructuredResultV2 } from "aws-lambda";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { KMSClient } from "@aws-sdk/client-kms";
import { SSMClient } from "@aws-sdk/client-ssm";
Expand Down Expand Up @@ -100,7 +100,7 @@ export function createGoogleOAuthCallbackHandler(dependencies: GoogleOAuthCallba
const logger = dependencies.logger ?? console;

return async function googleOAuthCallback(
event: APIGatewayProxyEventV2WithJWTAuthorizer,
event: APIGatewayProxyEventV2,
): Promise<APIGatewayProxyStructuredResultV2> {
const config = getConfig();
const code = event.queryStringParameters?.code;
Expand Down Expand Up @@ -170,7 +170,7 @@ export function createGoogleOAuthCallbackHandler(dependencies: GoogleOAuthCallba
}

export async function handler(
event: APIGatewayProxyEventV2WithJWTAuthorizer,
event: APIGatewayProxyEventV2,
): Promise<APIGatewayProxyStructuredResultV2> {
const config = getConfig();
const defaultHandler = createGoogleOAuthCallbackHandler({
Expand Down
24 changes: 15 additions & 9 deletions src/handlers/startGoogleOAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ describe("startGoogleOAuth", () => {
expect(response.body).toContain("Authentication required");
});

it("persists state and redirects to Google consent", async () => {
it("persists state and returns the Google consent URL", async () => {
const create = vi.fn().mockResolvedValue(undefined);
const handler = createStartGoogleOAuthHandler({
parameterStore: {
Expand Down Expand Up @@ -63,13 +63,19 @@ describe("startGoogleOAuth", () => {
createdAt: "2026-03-17T10:00:00.000Z",
expiresAt: "2026-03-17T10:10:00.000Z",
});
expect(response.statusCode).toBe(302);
expect(response.headers?.location).toContain("https://accounts.google.com/o/oauth2/v2/auth?");
expect(response.headers?.location).toContain("client_id=client-id");
expect(response.headers?.location).toContain("redirect_uri=https%3A%2F%2Fexample.com%2Fauth%2Fgoogle%2Fcallback");
expect(response.headers?.location).toContain("access_type=offline");
expect(response.headers?.location).toContain("prompt=consent");
expect(response.headers?.location).toContain("scope=openid+email+");
expect(response.headers?.location).toContain("state=state-123");
expect(response.statusCode).toBe(200);
expect(response.headers?.["content-type"]).toBe("application/json");

const body = JSON.parse(response.body ?? "{}");

expect(body.authorizationUrl).toContain("https://accounts.google.com/o/oauth2/v2/auth?");
expect(body.authorizationUrl).toContain("client_id=client-id");
expect(body.authorizationUrl).toContain(
"redirect_uri=https%3A%2F%2Fexample.com%2Fauth%2Fgoogle%2Fcallback",
);
expect(body.authorizationUrl).toContain("access_type=offline");
expect(body.authorizationUrl).toContain("prompt=consent");
expect(body.authorizationUrl).toContain("scope=openid+email+");
expect(body.authorizationUrl).toContain("state=state-123");
});
});
15 changes: 9 additions & 6 deletions src/handlers/startGoogleOAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,15 +106,18 @@ export function createStartGoogleOAuthHandler(
expiresAt,
});

const authorizationUrl = oauthClient.buildConsentUrl(
params.gmailOAuthClientId,
callbackUrl,
state,
);

return {
statusCode: 302,
statusCode: 200,
headers: {
location: oauthClient.buildConsentUrl(
params.gmailOAuthClientId,
callbackUrl,
state,
),
"content-type": "application/json",
},
body: JSON.stringify({ authorizationUrl }),
};
};
}
Expand Down
11 changes: 11 additions & 0 deletions template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@ Resources:
GmailTranslatorHttpApi:
Type: AWS::Serverless::HttpApi
Properties:
CorsConfiguration:
AllowOrigins:
- http://localhost:3000
AllowHeaders:
- authorization
- content-type
AllowMethods:
- GET
- OPTIONS
Auth:
DefaultAuthorizer: Auth0JwtAuthorizer
Authorizers:
Expand Down Expand Up @@ -303,6 +312,8 @@ Resources:
Path: /auth/google/callback
Method: GET
ApiId: !Ref GmailTranslatorHttpApi
Auth:
Authorizer: NONE
Metadata:
BuildMethod: esbuild
BuildProperties:
Expand Down
Loading