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
27 changes: 16 additions & 11 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,23 +135,28 @@ Feature design documents and implementation plans are in `docs/features/`. Each
- **Marketplace**: `docs/features/marketplace/` — Centralized marketplace, messaging, ratings, shipping settings, admin moderation
- **Hint Dismissing**: `docs/features/hint-dismissing.md` — Dismissable hint banners with `dismiss-hint` Stimulus controller, `HintType` enum, per-user persistence
- **Puzzle Insights**: `docs/features/puzzle-intelligence/` — Puzzle difficulty, player skill tiers, MSP rating, derived metrics
- **OAuth2 Server**: `docs/features/oauth2-server/` — OAuth2 authorization server for API access
- **API & OAuth2**: `docs/features/api/` — Public REST API (V1), OAuth2 server, Swagger docs, internal APIs, deprecated V0
- **Stripe Payments**: `docs/features/stripe.md` — Stripe integration for premium membership
- **Opt-Out Features**: `docs/features/opt-out.md` — Streak and ranking opt-out for players

### Feature Flags
Active feature flags are documented in `docs/features/feature_flags.md`. **Always read and update this file** when adding, modifying, or removing feature flags. It tracks which files are gated, what feature each flag belongs to, and when it can be removed.

### OAuth2 Server
- Powered by `league/oauth2-server-bundle`
- Endpoints: `/oauth2/authorize` (custom controller), `/oauth2/token` (bundle controller)
- API firewall (`^/api/v1/`) uses stateless Bearer token authentication
- Scopes: `profile:read` (default), `results:read`, `statistics:read`, `collections:read`
- Grants: `authorization_code`, `client_credentials`, `refresh_token` (password and implicit disabled)
- PKCE required for public clients only; confidential clients use client secret
- API endpoints: `GET /api/v1/me`, `GET /api/v1/players/{id}/results`, `GET /api/v1/players/{id}/statistics`
- User consent is tracked in `oauth2_user_consent` table (auto-approves previously consented scopes)
- Manage clients: `php bin/console myspeedpuzzling:oauth2:create-client`, `php bin/console myspeedpuzzling:oauth2:list-clients`
### API & Authentication
- **Two auth methods:** Personal Access Tokens (PAT) for own data, OAuth2 for third-party apps
- **PAT:** `msp_pat_*` tokens, hashed in DB, `PatAuthenticator` on `api` firewall, `ROLE_PAT`, own data only (`/api/v1/me/*`)
- **OAuth2:** `league/oauth2-server-bundle`, JWT Bearer tokens, scope-based roles
- **Scopes:** `profile:read` (default), `results:read`, `statistics:read`, `collections:read`, `solving-times:write`, `collections:write`
- **Grants:** `authorization_code` (read+write), `client_credentials` (read-only), `refresh_token`
- **"Me" endpoints:** `/api/v1/me/*` — PAT or OAuth2 with user context
- **Player endpoints:** `/api/v1/players/{id}/*` — OAuth2 only
- **Write endpoints:** `POST/PUT /api/v1/me/solving-times`, collection CRUD
- **Collections:** Membership gating — system collection (`default`) accessible to all, custom collections members-only
- **OAuth2 client registration:** Web form → admin approval → credential claim link (one-time display)
- **Audit:** `last_used_at` tracked for both PAT and OAuth2 tokens
- **`ApiUser` interface:** Shared by `PatUser` and `OAuth2User`, used by all providers
- **Fair Use Policy:** Required acceptance for PAT generation and OAuth2 client registration
- **Full docs:** `docs/features/api/README.md`

### Notable Features
- **Puzzle Time Tracking**: Sophisticated stopwatch with pause/resume and verification
Expand Down
2 changes: 2 additions & 0 deletions config/packages/api_platform.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@
'results:read' => 'Read player results',
'statistics:read' => 'Read player statistics',
'collections:read' => 'Read player collections',
'solving-times:write' => 'Create and edit solving times',
'collections:write' => 'Create, edit, and delete collections and items',
],
],
]);
Expand Down
2 changes: 2 additions & 0 deletions config/packages/league_oauth2_server.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
'results:read',
'statistics:read',
'collections:read',
'solving-times:write',
'collections:write',
],
'default' => [
'profile:read',
Expand Down
8 changes: 7 additions & 1 deletion config/packages/security.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Auth0\Symfony\Security\UserProvider;
use SpeedPuzzling\Web\Security\Auth0EntryPoint;
use SpeedPuzzling\Web\Security\OAuth2UserProvider;
use SpeedPuzzling\Web\Security\PatAuthenticator;
use Symfony\Component\Security\Core\Authorization\Voter\AuthenticatedVoter;

return App::config([
Expand Down Expand Up @@ -34,6 +35,7 @@
'stateless' => true,
'provider' => 'oauth2_provider',
'oauth2' => true,
'custom_authenticators' => [PatAuthenticator::class],
],
'main' => [
'pattern' => '^/',
Expand All @@ -54,7 +56,7 @@
],
[
'path' => '^/api/v1/me',
'roles' => ['ROLE_OAUTH2_PROFILE:READ'],
'roles' => [AuthenticatedVoter::IS_AUTHENTICATED_FULLY],
],
[
'path' => '^/api/v1/players/.*/results',
Expand All @@ -64,6 +66,10 @@
'path' => '^/api/v1/players/.*/statistics',
'roles' => ['ROLE_OAUTH2_STATISTICS:READ'],
],
[
'path' => '^/api/v1/players/.*/collections',
'roles' => ['ROLE_OAUTH2_COLLECTIONS:READ'],
],
[
'path' => '^/admin',
'roles' => [AuthenticatedVoter::IS_AUTHENTICATED_FULLY],
Expand Down
4 changes: 2 additions & 2 deletions config/reference.php
Original file line number Diff line number Diff line change
Expand Up @@ -1783,8 +1783,8 @@
* }
* @psalm-type MercureConfig = array{
* hubs?: array<string, array{ // Default: []
* url?: scalar|Param|null, // URL of the hub's publish endpoint // Default: null
* public_url?: scalar|Param|null, // URL of the hub's public endpoint
* url?: scalar|Param|null, // URL of the hub's publish endpoint
* public_url?: scalar|Param|null, // URL of the hub's public endpoint // Default: null
* jwt?: string|array{ // JSON Web Token configuration.
* value?: scalar|Param|null, // JSON Web Token to use to publish to this hub.
* provider?: scalar|Param|null, // The ID of a service to call to provide the JSON Web Token.
Expand Down
10 changes: 7 additions & 3 deletions config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,15 @@
$services->load('SpeedPuzzling\\Web\\Services\\', __DIR__ . '/../src/Services/**/{*.php}');
$services->load('SpeedPuzzling\\Web\\Query\\', __DIR__ . '/../src/Query/**/{*.php}');
$services->load('SpeedPuzzling\\Web\\Security\\', __DIR__ . '/../src/Security/**/{*.php}')
->exclude([__DIR__ . '/../src/Security/OAuth2User.php']);
->exclude([
__DIR__ . '/../src/Security/OAuth2User.php',
__DIR__ . '/../src/Security/PatUser.php',
__DIR__ . '/../src/Security/ApiUser.php',
]);
$services->load('SpeedPuzzling\\Web\\EventSubscriber\\', __DIR__ . '/../src/EventSubscriber/**/{*.php}');

// API Resource Providers
$services->load('SpeedPuzzling\\Web\\Api\\', __DIR__ . '/../src/Api/**/{*Provider.php}');
// API Resource Providers and Processors
$services->load('SpeedPuzzling\\Web\\Api\\', __DIR__ . '/../src/Api/**/{*Provider.php,*Processor.php}');

// Components
$services->load('SpeedPuzzling\\Web\\Component\\', __DIR__ . '/../src/Component/**/{*.php}');
Expand Down
243 changes: 243 additions & 0 deletions docs/features/api/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
# API

## Overview

MySpeedPuzzling exposes a REST API (`/api/v1/`) for third-party integrations and personal use. The API supports two authentication methods:

- **Personal Access Tokens (PAT)** — self-service tokens for accessing your own data
- **OAuth2** — for third-party applications accessing user data with consent

The API is built with API Platform and documented via Swagger UI at `/api/docs`.

## Authentication

### Personal Access Tokens (PAT)

PATs are long-lived tokens for accessing your own data only. Any logged-in user can create multiple named PATs from their profile settings.

- **Format:** `msp_pat_` + 48 hex characters
- **Header:** `Authorization: Token msp_pat_...`
- **Access:** Own data only (`/api/v1/me/*` endpoints)
- **Cannot** access other players' data
- **No scopes** — full read/write access to own data
- **Fair Use Policy** must be accepted before generating
- **Management:** Create, revoke, and view usage in profile settings
- **Audit:** `last_used_at` tracked on every request

### OAuth2

Built on `league/oauth2-server-bundle`. Supports two flows:

**Authorization Code** (user-facing apps) — users authorize third-party apps to access their data with consent. Read and write access per granted scopes.

**Client Credentials** (service-to-service) — read-only access to any non-hidden player's public data. No user context.

### Scopes

| Scope | Description | Auth Code | Client Credentials |
|-------|-------------|-----------|-------------------|
| `profile:read` (default) | View profile info | Yes | Yes |
| `results:read` | View puzzle solving results | Yes | Yes |
| `statistics:read` | View solving statistics | Yes | Yes |
| `collections:read` | View puzzle collections | Yes | Yes |
| `solving-times:write` | Create and edit solving times | Yes | No |
| `collections:write` | Create, edit, delete collections and items | Yes | No |

### Token TTLs

- Access token: 1 hour (stateless JWT)
- Refresh token: 1 month
- Auth code: 10 minutes

## Endpoints

### "Me" Endpoints (PAT + OAuth2 with user context)

| Method | Endpoint | Required |
|--------|----------|----------|
| GET | `/api/v1/me` | PAT or `profile:read` |
| GET | `/api/v1/me/results?type=solo\|duo\|team` | PAT or `results:read` |
| GET | `/api/v1/me/statistics` | PAT or `statistics:read` |
| POST | `/api/v1/me/solving-times` | PAT or `solving-times:write` |
| PUT | `/api/v1/me/solving-times/{timeId}` | PAT or `solving-times:write` |
| GET | `/api/v1/me/collections` | PAT or `collections:read` |
| GET | `/api/v1/me/collections/{id}/items` | PAT or `collections:read` |
| POST | `/api/v1/me/collections` | PAT or `collections:write` (members only) |
| PUT | `/api/v1/me/collections/{id}` | PAT or `collections:write` (members only) |
| DELETE | `/api/v1/me/collections/{id}` | PAT or `collections:write` (members only) |
| POST | `/api/v1/me/collections/{id}/items` | PAT or `collections:write` |
| DELETE | `/api/v1/me/collections/{id}/items/{itemId}` | PAT or `collections:write` |

### Player Endpoints (OAuth2 only)

| Method | Endpoint | Scope |
|--------|----------|-------|
| GET | `/api/v1/players/{id}/results?type=solo\|duo\|team` | `results:read` |
| GET | `/api/v1/players/{id}/statistics` | `statistics:read` |
| GET | `/api/v1/players/{id}/collections` | `collections:read` (public only) |
| GET | `/api/v1/players/{id}/collections/{cid}/items` | `collections:read` (public only) |

### Collection Membership Gating

- **System collection** (`id=default`): All users can list/add/remove items
- **Custom collections**: Only members can create, edit, delete, and manage items
- API returns 403 when non-member attempts members-only collection operation

### Members-Exclusive Data

Puzzle difficulty and player skill tiers are included in responses only if the token owner has active membership. Non-members see `null` for these fields.

### POST `/api/v1/me/solving-times`

```json
{
"puzzle_id": "uuid",
"time": "1:23:45",
"comment": "Optional comment",
"finished_at": "2025-12-01T14:30:00+00:00",
"first_attempt": true,
"unboxed": false,
"group_players": ["#PLAYER_CODE", "Guest Name"]
}
```

- `time` format: `HH:MM:SS` or `MM:SS`
- `group_players`: player codes prefixed with `#`, or plain names for unregistered players
- Photo uploads not supported via API (use the website)

### Privacy

- `/api/v1/me/*` always returns full data for the token owner
- `/api/v1/players/{id}/*` returns empty/zeroed data for private profiles (not 403)
- Hidden players are never returned in service-to-service queries

### Error Handling

- Missing/invalid/expired token: 401
- Missing scope: 403
- Non-existent player UUID: 404
- Membership required: 403 with message
- Validation error: 422
- Error format: `application/json` and `application/problem+json` (RFC 7807)

## OAuth2 Client Registration

### Self-Service Flow

1. User navigates to `/en/request-api-access` (linked from `/en/for-developers` and profile settings)
2. Fills in: app name, description, purpose, application type (confidential/public), scopes, redirect URIs
3. Accepts Fair Use Policy
4. Admin receives email notification about the new request
5. Admin reviews at `/admin/oauth2-requests` — approve or reject with reason
6. **On approval:** User receives email with a one-time credential claim link (valid 7 days)
7. User clicks link → sees client ID + secret once → saves them securely
8. Credentials are never shown again after claiming

### Credential Management

- Users can view their applications in profile settings ("My Applications" section)
- Approved apps can reset credentials (generates new secret, revokes all tokens, sends new claim link)

### CLI Client Management

```bash
php bin/console myspeedpuzzling:oauth2:create-client "App Name" app-identifier \
--redirect-uri=https://example.com/callback \
--grant-type=authorization_code \
--grant-type=refresh_token \
--scope=profile:read
```

Add `--public` for public clients (PKCE required, no secret).

```bash
php bin/console myspeedpuzzling:oauth2:list-clients
```

## Audit Trail

- **PAT:** `last_used_at` updated on every authenticated API request (in `PatAuthenticator`)
- **OAuth2:** `last_used_at` on `oauth2_user_consent` updated on API requests (throttled to every 5 minutes, in `ApiTokenUsageSubscriber`)
- Visible in profile settings for both PATs and connected applications

## Security Architecture

Three authenticators on the `api` firewall (`^/api/v1/`):
- `PatAuthenticator` — handles `Bearer msp_pat_*` tokens, creates `PatUser` with `ROLE_PAT`
- OAuth2 authenticator (from bundle) — handles JWT Bearer tokens, creates `OAuth2User` with scope-based roles

Both `PatUser` and `OAuth2User` implement the `ApiUser` interface (`getPlayer(): Player`).

Access control:
- `^/api/v1/me` → `IS_AUTHENTICATED_FULLY` (PAT or OAuth2)
- `^/api/v1/players/.*/results` → `ROLE_OAUTH2_RESULTS:READ`
- `^/api/v1/players/.*/statistics` → `ROLE_OAUTH2_STATISTICS:READ`
- `^/api/v1/players/.*/collections` → `ROLE_OAUTH2_COLLECTIONS:READ`

## Fair Use Policy

Page at `/en/fair-use-policy` — content placeholder (to be filled). Required acceptance for both PAT generation and OAuth2 client registration.

## CORS

Configured in `config/packages/nelmio_cors.php` with `allow_origin: ['*']` globally.

## Deprecated: V0 Legacy API

> **Deprecated** — Do not develop further. Kept for backward compatibility only.

`GET /api/v0/players/{playerId}/results?token=...` — effectively unauthenticated. Lives in `src/Controller/Api/V0/`.

## Internal APIs (not for public use)

### Stopwatch API (`/api/stopwatch/`)

Session-authenticated timer management for the web app's Stimulus controller.

### Mobile Billing (`/api/android/`, `/api/ios/`)

Stub endpoints for in-app purchase verification (not implemented).

## Environment Variables

| Variable | Description |
|----------|-------------|
| `OAUTH2_PRIVATE_KEY` | RSA private key for signing tokens |
| `OAUTH2_PUBLIC_KEY` | RSA public key for verifying tokens |
| `OAUTH2_PASSPHRASE` | Private key passphrase (empty in dev) |
| `OAUTH2_ENCRYPTION_KEY` | Encryption key for refresh tokens |

## Database Tables

| Table | Description |
|-------|-------------|
| `personal_access_token` | PAT storage (hashed tokens, audit trail) |
| `oauth2_client` | Registered OAuth2 clients |
| `oauth2_client_request` | OAuth2 client registration requests (pending/approved/rejected) |
| `oauth2_access_token` | Issued access tokens |
| `oauth2_authorization_code` | Short-lived auth codes (10 min) |
| `oauth2_refresh_token` | Refresh tokens (1 month) |
| `oauth2_user_consent` | User consent per client/scope with `last_used_at` tracking |

## Key Files

| File | Purpose |
|------|---------|
| `src/Security/ApiUser.php` | Shared interface for PAT and OAuth2 users |
| `src/Security/PatUser.php` | PAT user with `ROLE_PAT` |
| `src/Security/PatAuthenticator.php` | PAT token authenticator |
| `src/Security/OAuth2User.php` | OAuth2 user (implements `ApiUser`) |
| `src/Security/OAuth2UserProvider.php` | Loads Player by UUID from JWT |
| `src/Entity/PersonalAccessToken.php` | PAT entity (hashed token, audit fields) |
| `src/Entity/OAuth2/OAuth2ClientRequest.php` | Client registration request entity |
| `src/Entity/OAuth2/OAuth2UserConsent.php` | Consent entity with `lastUsedAt` |
| `src/Api/V1/` | All API Platform resources, providers, and processors |
| `src/Controller/OAuth2/RequestApiAccessController.php` | OAuth2 client registration form |
| `src/Controller/OAuth2/ClaimOAuth2CredentialsController.php` | One-time credential display |
| `src/Controller/Admin/OAuth2ClientRequests*.php` | Admin review pages |
| `src/EventSubscriber/ApiTokenUsageSubscriber.php` | OAuth2 usage tracking |
| `config/packages/security.php` | Firewalls and access control |
| `config/packages/league_oauth2_server.php` | OAuth2 config (scopes, grants, TTLs) |
| `config/packages/api_platform.php` | API Platform config and Swagger |
| `templates/oauth2/request-api-access.html.twig` | Client registration form |
| `templates/oauth2/claim-credentials.html.twig` | One-time credential display |
Loading
Loading