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
6 changes: 5 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,11 @@ DEVICE_PURGE_CRON=0 6 * * *
SUBSCRIPTION_RECONCILE_CRON=0 3 * * *
SUBSCRIPTION_RECONCILE_BATCH_SIZE=500

# Shared secret Pub/Sub appends to the Play notification push URL. Empty keeps
# How a Play notification proves it came from Google. Set both and the shared
# secret below stops being consulted.
PLAY_OIDC_AUDIENCE=
PLAY_OIDC_SERVICE_ACCOUNT=
# Fallback: shared secret Pub/Sub appends to the Play notification push URL. Empty keeps
# that endpoint closed - it writes billing state and carries no session.
PLAY_NOTIFICATIONS_TOKEN=

Expand Down
23 changes: 16 additions & 7 deletions docs/verified-badge.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,10 +139,19 @@ nothing would ever connect that token to that account again; the nightly
reconcile finishes it.

**`POST /api/v1/billing/play/notifications`** — where Pub/Sub pushes Google's
notifications. No session, guarded by a shared secret on the URL
(`?token=…`, `PLAY_NOTIFICATIONS_TOKEN`). **Empty means the endpoint is
closed**, which is the right default for an unauthenticated route that writes
billing state.
notifications. No session, so it is guarded two ways and the better one wins:

- **OIDC** (`PLAY_OIDC_AUDIENCE` + `PLAY_OIDC_SERVICE_ACCOUNT`). Pub/Sub signs
the push with a Google identity token; the signature is checked against
Google's published keys, and both the audience and the service account it is
signed as must match. Configure this on the push subscription and here, and
it is the only thing consulted.
- **A shared secret on the URL** (`?token=…`, `PLAY_NOTIFICATIONS_TOKEN`), for
a subscription created without OIDC. Weaker, and knowingly so: a query string
is written into this service's access logs and into every proxy's.

**With neither configured the endpoint is closed**, which is the right default
for an unauthenticated route that writes billing state.

The notification is treated as a **nudge, never as state**. It says a purchase
changed; what it changed to is then read from the Play Developer API, and that
Expand Down Expand Up @@ -170,16 +179,16 @@ badge is granted. It needs the Play Console work: a subscription product, a
service account with "View financial data" and "Manage orders and
subscriptions", and the Pub/Sub topic.

Verifying the OIDC token Google can attach to a push is the stronger
alternative to the shared secret, and belongs with that same work.

## Settings

| Variable | Default | What it does |
| --- | --- | --- |
| `SUBSCRIPTION_RECONCILE_CRON` | `0 3 * * *` | When the repair pass runs. |
| `SUBSCRIPTION_RECONCILE_BATCH_SIZE` | `500` | Rows examined per pass. |
| `PLAY_NOTIFICATIONS_TOKEN` | _(empty)_ | Shared secret on the push URL. Empty closes the endpoint. |
| `PLAY_OIDC_AUDIENCE` | _(empty)_ | Audience set on the Pub/Sub push subscription. |
| `PLAY_OIDC_SERVICE_ACCOUNT` | _(empty)_ | Service account Pub/Sub signs the push as. |
| `PLAY_NOTIFICATIONS_TOKEN` | _(empty)_ | Fallback shared secret on the push URL, used only when OIDC is unconfigured. |

There is no provider yet. `NoopBillingService` stands in, and it deliberately
never reports a subscription as active — a stub that granted entitlements would
Expand Down
4 changes: 4 additions & 0 deletions render.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,10 @@ projects:
# Shared secret on the Play notification push URL. Empty keeps that
# endpoint closed, which is the right default for an unauthenticated
# route that writes billing state.
- key: PLAY_OIDC_AUDIENCE
sync: false
- key: PLAY_OIDC_SERVICE_ACCOUNT
sync: false
- key: PLAY_NOTIFICATIONS_TOKEN
sync: false
# The paid verification badge. Both have defaults; they are declared
Expand Down
9 changes: 9 additions & 0 deletions src/core/ports/services/google-auth.port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ export interface GoogleProfile {

/** The derived username based on the user's Google email address. */
username: string;

/**
* Whether Google says it has verified this address.
*
* Read rather than assumed. An OAuth login matches an existing account by
* email, so an unverified address is a way to walk into somebody else's
* account by claiming to own their mailbox.
*/
isEmailVerified: boolean;
}

/**
Expand Down
12 changes: 12 additions & 0 deletions src/core/use-cases/oauth/oauth-github/github-login.usecase.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { GithubLoginInput } from "./github-login.input";
import { UnauthorizedError } from "@core/errors";
import type { GithubAuthPort } from "@core/ports/services/github-auth.port";
import type { IUserRepository } from "@core/ports/repositories/user.repository";
import type { AuthTokenPort } from "@core/ports/services/auth-token.port";
Expand All @@ -23,6 +24,17 @@ export class GithubLoginUseCase {
input.code,
);

// Refused before the account is even looked up. This flow matches an
// existing account by email alone, so an address the provider has not
// verified is a way to walk into somebody else's account by claiming
// to own their mailbox - sign up at the provider with their address,
// authorise, and be handed their session.
if (!profile.isEmailVerified) {
throw new UnauthorizedError(
"GitHub has not verified this email address.",
);
}

let user = await this.userRepository.findByEmail(profile.email);

if (user) {
Expand Down
14 changes: 13 additions & 1 deletion src/core/use-cases/oauth/oauth-google/google.login.usecase.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { GoogleAuthPort } from "@core/ports/services/google-auth.port";
import { UnauthorizedError } from "@core/errors";
import type { IUserRepository } from "@core/ports/repositories/user.repository";
import type { AuthTokenPort } from "@core/ports/services/auth-token.port";
import { AccountBannedError, AccountPendingDeletionError } from "@core/errors";
Expand All @@ -23,6 +24,17 @@ export class GoogleLoginUseCase {
input.code,
);

// Refused before the account is even looked up. This flow matches an
// existing account by email alone, so an address the provider has not
// verified is a way to walk into somebody else's account by claiming
// to own their mailbox - sign up at the provider with their address,
// authorise, and be handed their session.
if (!profile.isEmailVerified) {
throw new UnauthorizedError(
"Google has not verified this email address.",
);
}

let user = await this.userRepository.findByEmail(profile.email);

if (user) {
Expand Down Expand Up @@ -53,7 +65,7 @@ export class GoogleLoginUseCase {
username: finalUsername,
provider: "google",
providerAccountId: profile.providerAccountId,
isEmailVerified: true,
isEmailVerified: profile.isEmailVerified,
});
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ForbiddenError } from "@core/errors";
import { assertSafeSocialLinks } from "@core/use-cases/shared/profile/social-links";
import type { IProfileRepository } from "@core/ports/repositories/profile.repository";
import type { IUserRepository } from "@core/ports/repositories/user.repository";
import type { UpdateProfileInput } from "./update-profile-usecase.input";
Expand Down Expand Up @@ -38,6 +39,8 @@ export class UpdateProfileUseCase {
* @throws {ForbiddenError} When a non-bot account tries to set categories.
*/
async execute(input: UpdateProfileInput): Promise<void> {
assertSafeSocialLinks(input.socials);

if (input.categories !== undefined) {
const user = await this.userRepository.findById(input.userId);

Expand Down
71 changes: 71 additions & 0 deletions src/core/use-cases/shared/profile/social-links.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { BadRequestError } from "@core/errors";

/** Schemes a link on a profile may use. */
const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]);

/** Longest platform name accepted as a key. */
const MAX_KEY_LENGTH = 20;

/** Longest link accepted. */
const MAX_VALUE_LENGTH = 300;

/** Most links one profile may carry. */
const MAX_LINKS = 10;

const KEY_PATTERN = /^[a-z][a-z0-9_]*$/;

/**
* Checks the links a profile carries before they are stored.
*
* `format: "uri"` in the schema is not this check. RFC 3986 asks for *a*
* scheme and nothing more, so `javascript:`, `data:` and `vbscript:` all
* satisfy it - and this is the one field on the platform explicitly typed as a
* URL, which is exactly the field a client will render as an `href` without
* thinking. A profile is public, so anything stored here is served to anyone
* who asks.
*
* The keys are constrained too: they are platform names, and an unbounded
* record of arbitrary strings is a place for somebody to keep whatever they
* like at everyone else's expense.
*
* @param socials - The links submitted, if any
*
* @throws BadRequestError - When a key, a link or the count is unacceptable
*/
export function assertSafeSocialLinks(
socials: Record<string, string> | null | undefined,
): void {
if (!socials) return;

const entries = Object.entries(socials);

if (entries.length > MAX_LINKS) {
throw new BadRequestError(
`A profile may carry at most ${MAX_LINKS} links.`,
);
}

for (const [key, value] of entries) {
if (key.length > MAX_KEY_LENGTH || !KEY_PATTERN.test(key)) {
throw new BadRequestError(`"${key}" is not a valid link name.`);
}

if (value.length > MAX_VALUE_LENGTH) {
throw new BadRequestError(`The link for "${key}" is too long.`);
}

let protocol: string;

try {
protocol = new URL(value).protocol;
} catch {
throw new BadRequestError(`The link for "${key}" is not a URL.`);
}

if (!ALLOWED_PROTOCOLS.has(protocol)) {
throw new BadRequestError(
`Links must be http or https; "${key}" is not.`,
);
}
}
}
7 changes: 4 additions & 3 deletions src/http/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { UnauthorizedError } from "@core/errors";
import { clientIp } from "@plugins/shared/client-ip";
import type { ForgotPasswordUseCase } from "@core/use-cases/auth/forgot-password";
import type { LoginUseCase } from "@core/use-cases/auth/login";
import type { LogoutUseCase } from "@core/use-cases/auth/logout";
Expand Down Expand Up @@ -63,7 +64,7 @@ export class AuthController extends BaseAuthController {
const response = await this.loginUseCase.execute({
identifier: request.body.identifier,
password: request.body.password,
deviceIp: request.ip,
deviceIp: clientIp(request),
userAgent: request.headers["user-agent"] ?? "Unknown Device",
});

Expand Down Expand Up @@ -101,7 +102,7 @@ export class AuthController extends BaseAuthController {

const response = await this.refreshUseCase.execute({
token,
deviceIp: request.ip,
deviceIp: clientIp(request),
userAgent: request.headers["user-agent"] ?? "Unknown Device",
});

Expand Down Expand Up @@ -200,7 +201,7 @@ export class AuthController extends BaseAuthController {
): Promise<void> {
const response = await this.recoverAccountUseCase.execute({
recoveryToken: request.body.recoveryToken,
deviceIp: request.ip,
deviceIp: clientIp(request),
userAgent: request.headers["user-agent"] ?? "Unknown Device",
});

Expand Down
3 changes: 2 additions & 1 deletion src/http/controllers/oauth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { BaseAuthController } from "./base-auth.controller";
import { clientIp } from "@plugins/shared/client-ip";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { AccountPendingDeletionError } from "@core/errors";
import type { GithubAuthPort } from "@core/ports/services/github-auth.port";
Expand Down Expand Up @@ -136,7 +137,7 @@ export class OAuthController extends BaseAuthController {
): Promise<void> {
const response = await this.oauthExchangeUseCase.execute({
code: request.body.code,
deviceIp: request.ip,
deviceIp: clientIp(request),
userAgent: request.headers["user-agent"] ?? "Unknown Device",
});

Expand Down
42 changes: 36 additions & 6 deletions src/http/controllers/play-billing.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { UnauthorizedError } from "@core/errors";
import { timingSafeEqual } from "node:crypto";
import type { RegisterPlayPurchaseUseCase } from "@core/use-cases/billing/register-play-purchase";
import type { PlayNotificationService } from "@infrastructure/external/billing/play/play-notification.service";
import type { GoogleOidcVerifier } from "@infrastructure/external/billing/play/google-oidc-verifier";
import type {
PlayNotificationQuery,
RegisterPlayPurchaseBody,
Expand Down Expand Up @@ -48,11 +49,13 @@ export class PlayBillingController {
*
* @param registerPlayPurchaseUseCase - Attaches a purchase to an account
* @param playNotificationService - Handles what Google pushes
* @param googleOidcVerifier - Proves a push really came from Google
* @param config - Environment configuration, for the push secret
*/
constructor(
private readonly registerPlayPurchaseUseCase: RegisterPlayPurchaseUseCase,
private readonly playNotificationService: PlayNotificationService,
private readonly googleOidcVerifier: GoogleOidcVerifier,
private readonly config: FastifyInstance["config"],
) {}

Expand Down Expand Up @@ -100,12 +103,7 @@ export class PlayBillingController {
request: FastifyRequest<{ Querystring: PlayNotificationQuery }>,
reply: FastifyReply,
): Promise<void> {
const expected = this.config.PLAY_NOTIFICATIONS_TOKEN;

// No secret configured means the endpoint is not wired up yet. Closed
// rather than open: an unauthenticated endpoint that writes billing
// state is not something to leave ajar by default.
if (!expected || !matchesSecret(request.query.token, expected)) {
if (!(await this.callerIsGoogle(request))) {
throw new UnauthorizedError();
}

Expand All @@ -118,4 +116,36 @@ export class PlayBillingController {

reply.status(204).send();
}

/**
* Decides whether a push really came from Google.
*
* Two mechanisms, and the better one wins where it is available. A signed
* identity token proves the caller; a shared secret in the query string
* only proves they have read something that ends up in access logs - ours
* and every proxy's. The secret stays because a Pub/Sub subscription can
* be created without OIDC, and a deployment part-way through being wired
* up should not silently start accepting anything.
*
* With neither configured the endpoint is closed, which is the right
* default for an unauthenticated route that writes billing state.
*
* @param request - The push request
* @returns True when the caller is accepted
*/
private async callerIsGoogle(
request: FastifyRequest<{ Querystring: PlayNotificationQuery }>,
): Promise<boolean> {
if (this.googleOidcVerifier.isConfigured) {
return this.googleOidcVerifier.verify(
request.headers.authorization,
);
}

const expected = this.config.PLAY_NOTIFICATIONS_TOKEN;

return (
Boolean(expected) && matchesSecret(request.query.token, expected)
);
}
}
7 changes: 6 additions & 1 deletion src/http/controllers/profile.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,13 @@ export class ProfileController {
const body = request.body;

await this.updateProfileUseCase.execute({
userId,
// Identity last. Nothing exploitable reaches here today - the
// schema sets `additionalProperties: false` and AJV strips the
// rest - but this use case authorises on `input.userId`, so a body
// supplying its own would satisfy that check rather than fail it,
// and the ordering should not be the thing standing in the way.
...body,
userId,
});

reply.status(204).send();
Expand Down
14 changes: 14 additions & 0 deletions src/http/plugins/di/external.di.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from "@infrastructure/external/push/expo-push.service";
import { NoopBillingService } from "@infrastructure/external/billing/noop-billing.service";
import { PlayNotificationService } from "@infrastructure/external/billing/play/play-notification.service";
import { GoogleOidcVerifier } from "@infrastructure/external/billing/play/google-oidc-verifier";
import { GithubAuthService } from "@infrastructure/external/github-auth.service";
import { GoogleAuthService } from "@infrastructure/external/google-auth.service";
import { S3StorageService } from "@infrastructure/external/s3-storage.service";
Expand Down Expand Up @@ -45,6 +46,19 @@ export const externalModule = {
*/
playNotificationService: asClass(PlayNotificationService).singleton(),

/**
* Proves a Play notification came from Google rather than from somebody
* who read a log line. Unconfigured, the endpoint falls back to the
* shared secret on the push URL.
*/
googleOidcVerifier: asFunction(
(config) =>
new GoogleOidcVerifier({
audience: config.PLAY_OIDC_AUDIENCE,
serviceAccountEmail: config.PLAY_OIDC_SERVICE_ACCOUNT,
}),
).singleton(),

emailService: asFunction((config, logger) => {
return new EmailService(
{
Expand Down
Loading