Skip to content

Commit 15b580a

Browse files
committed
Improve auth sandbox and smoke test workflow
1 parent 812b491 commit 15b580a

14 files changed

Lines changed: 526 additions & 49 deletions

File tree

.env.example

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,26 +11,44 @@ RATE_LIMIT_AUTH=5
1111
RATE_LIMIT_GENERAL=100
1212
RATE_LIMIT_UPLOAD=10
1313
RATE_LIMIT_BATCH=20
14-
PUBLIC_BASE_URL=
14+
15+
# Public server URL used in OAuth callbacks and email links.
16+
# Local desktop testing: http://localhost:8080
17+
# Mobile/device or real Google testing: use a public HTTPS URL or tunnel.
18+
PUBLIC_BASE_URL=http://localhost:8080
19+
20+
# Google OAuth web-application credentials.
21+
# Authorized redirect URI should be:
22+
# <PUBLIC_BASE_URL>/v1/auth/oauth/google/callback
1523
GOOGLE_OAUTH_CLIENT_ID=
1624
GOOGLE_OAUTH_CLIENT_SECRET=
1725
OAUTH_STATE_EXPIRE_MINUTES=10
1826
AUTH_EXCHANGE_CODE_EXPIRE_MINUTES=5
1927
EMAIL_VERIFICATION_TOKEN_EXPIRE_MINUTES=1440
2028
PASSWORD_RESET_TOKEN_EXPIRE_MINUTES=60
21-
EMAIL_DELIVERY_ENABLED=false
22-
SMTP_HOST=
23-
SMTP_PORT=587
29+
30+
# Local Mailpit defaults when running the API on the host machine.
31+
# If the API itself runs in Docker, use SMTP_HOST=mailpit instead.
32+
EMAIL_DELIVERY_ENABLED=true
33+
SMTP_HOST=127.0.0.1
34+
SMTP_PORT=1025
2435
SMTP_USERNAME=
2536
SMTP_PASSWORD=
26-
SMTP_USE_TLS=true
37+
SMTP_USE_TLS=false
2738
SMTP_USE_SSL=false
28-
SMTP_FROM_EMAIL=
39+
SMTP_FROM_EMAIL=noreply@papyrus.local
2940
SMTP_FROM_NAME=Papyrus
41+
42+
# Prefer file-based PowerSync keys for local development.
43+
# Generate them with: ./scripts/generate_dev_powersync_keys.sh
44+
POWERSYNC_JWT_PRIVATE_KEY_FILE=.local/powersync/private.pem
45+
POWERSYNC_JWT_PUBLIC_KEY_FILE=.local/powersync/public.pem
46+
47+
# Inline PEM values are still supported if you need them.
3048
POWERSYNC_JWT_PRIVATE_KEY=
3149
POWERSYNC_JWT_PUBLIC_KEY=
32-
POWERSYNC_JWT_KEY_ID=papyrus-powersync-v1
33-
POWERSYNC_JWT_AUDIENCE=
50+
POWERSYNC_JWT_KEY_ID=papyrus-powersync-dev
51+
POWERSYNC_JWT_AUDIENCE=powersync-dev
3452
POWERSYNC_TOKEN_EXPIRE_MINUTES=5
3553
POSTGRES_USER=papyrus
3654
POSTGRES_PASSWORD=papyrus

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ env/
3030
.env
3131
.env.local
3232
.env.*.local
33+
.local/
3334

3435
# IDE
3536
.idea/

README.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ uv sync --extra dev
1313
Run the database:
1414

1515
```bash
16-
docker compose up database
16+
docker compose up -d database mailpit
1717
```
1818

1919
Run database migrations:
@@ -28,10 +28,22 @@ Run the server:
2828
uv run uvicorn papyrus.main:app --reload
2929
```
3030

31+
Generate local PowerSync keys for auth testing:
32+
33+
```bash
34+
./scripts/generate_dev_powersync_keys.sh
35+
```
36+
3137
## Development
3238

3339
Run tests:
3440

3541
```bash
3642
uv run pytest --cov --cov-report html
3743
```
44+
45+
## Auth Testing
46+
47+
Local auth testing supports Mailpit for SMTP capture, a dev auth sandbox at `/__dev/auth-sandbox`, and opt-in provider smoke tests.
48+
49+
See [`docs/auth-testing.md`](docs/auth-testing.md) for the exact `.env` values, Google OAuth setup, and end-to-end test workflow.

docker-compose.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ services:
1616
timeout: 5s
1717
retries: 5
1818

19+
mailpit:
20+
image: axllent/mailpit:v1.26
21+
restart: unless-stopped
22+
ports:
23+
- "1025:1025"
24+
- "8025:8025"
25+
1926
server:
2027
build: .
2128
restart: unless-stopped
@@ -25,6 +32,8 @@ services:
2532
depends_on:
2633
database:
2734
condition: service_healthy
35+
mailpit:
36+
condition: service_started
2837
command: >
2938
sh -c "alembic upgrade head && papyrus-server"
3039

docs/auth-testing.md

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
# Authentication Testing Setup
2+
3+
This repo can now support a full local auth test loop:
4+
5+
- email/password login
6+
- refresh and logout flows
7+
- verification and password-reset emails
8+
- PowerSync token minting
9+
- Google OAuth browser flow after you add Google credentials
10+
11+
## Local Setup
12+
13+
1. Start local dependencies:
14+
15+
```bash
16+
docker compose up -d database mailpit
17+
```
18+
19+
1. Generate local PowerSync signing keys:
20+
21+
```bash
22+
./scripts/generate_dev_powersync_keys.sh
23+
```
24+
25+
1. Make sure `.env` contains the auth values shown in `.env.example`.
26+
27+
Recommended local values when the API runs on the host with `uvicorn`:
28+
29+
```dotenv
30+
PUBLIC_BASE_URL=http://localhost:8080
31+
EMAIL_DELIVERY_ENABLED=true
32+
SMTP_HOST=127.0.0.1
33+
SMTP_PORT=1025
34+
SMTP_USE_TLS=false
35+
SMTP_USE_SSL=false
36+
SMTP_FROM_EMAIL=noreply@papyrus.local
37+
SMTP_FROM_NAME=Papyrus
38+
POWERSYNC_JWT_PRIVATE_KEY_FILE=.local/powersync/private.pem
39+
POWERSYNC_JWT_PUBLIC_KEY_FILE=.local/powersync/public.pem
40+
POWERSYNC_JWT_KEY_ID=papyrus-powersync-dev
41+
POWERSYNC_JWT_AUDIENCE=powersync-dev
42+
```
43+
44+
If the API runs inside Docker instead of on the host, set `SMTP_HOST=mailpit` and `POSTGRES_HOST=database`.
45+
46+
1. Apply migrations and run the API:
47+
48+
```bash
49+
uv run alembic upgrade head
50+
uv run uvicorn papyrus.main:app --reload
51+
```
52+
53+
## Useful Local Pages
54+
55+
- API index: `http://localhost:8080/`
56+
- Swagger UI: `http://localhost:8080/docs`
57+
- ReDoc: `http://localhost:8080/redoc`
58+
- Dev auth sandbox: `http://localhost:8080/__dev/auth-sandbox`
59+
- Mailpit inbox UI: `http://localhost:8025`
60+
61+
## SMTP End-to-End Testing
62+
63+
Mailpit is a local SMTP sink. No real mailbox is needed.
64+
65+
- Trigger `forgot password` or `resend verification` from the sandbox or API.
66+
- Open `http://localhost:8025` to inspect the delivered messages.
67+
- For the opt-in smoke test, use any recipient address:
68+
69+
```bash
70+
RUN_SMTP_SMOKE_TEST=true \
71+
AUTH_SMOKE_EMAIL_RECIPIENT=smoke@papyrus.local \
72+
uv run pytest tests/integration/test_auth_smoke.py -m auth_smoke -q
73+
```
74+
75+
## Google OAuth Setup
76+
77+
Papyrus uses a server-owned browser OAuth flow. The Flutter app opens:
78+
79+
- `GET /v1/auth/oauth/google/start`
80+
81+
Google redirects back to the server callback:
82+
83+
- `GET /v1/auth/oauth/google/callback`
84+
85+
The server then redirects to your app callback URI with a one-time Papyrus exchange code.
86+
87+
### What To Create In Google Cloud
88+
89+
Create an OAuth client with:
90+
91+
- Client type: `Web application`
92+
- Redirect URI:
93+
- local desktop testing: `http://localhost:8080/v1/auth/oauth/google/callback`
94+
- public tunnel/device testing: `https://<your-public-host>/v1/auth/oauth/google/callback`
95+
96+
Authorized JavaScript origins are not required for this backend-owned redirect flow. If the Google UI requires one for localhost testing, use:
97+
98+
- `http://localhost:8080`
99+
100+
Set the resulting values in `.env`:
101+
102+
```dotenv
103+
GOOGLE_OAUTH_CLIENT_ID=...
104+
GOOGLE_OAUTH_CLIENT_SECRET=...
105+
PUBLIC_BASE_URL=http://localhost:8080
106+
```
107+
108+
For mobile-device testing or any device where the browser cannot reach your workstation as `localhost`, use a public HTTPS base URL and set `PUBLIC_BASE_URL` to that exact value.
109+
110+
### Localhost vs Public Testing
111+
112+
- Desktop same-machine testing:
113+
- `PUBLIC_BASE_URL=http://localhost:8080`
114+
- Google redirect URI: `http://localhost:8080/v1/auth/oauth/google/callback`
115+
- Mobile emulator, physical phone, or shared test device:
116+
- expose the backend through a public HTTPS URL
117+
- set `PUBLIC_BASE_URL=https://<your-public-host>`
118+
- Google redirect URI: `https://<your-public-host>/v1/auth/oauth/google/callback`
119+
120+
The callback URI must match Google exactly, including scheme, host, port, path, and trailing slash behavior.
121+
122+
### OAuth Consent Screen Notes
123+
124+
For development:
125+
126+
- keep the app in testing mode
127+
- add your Google account under test users if Google requires it
128+
129+
Papyrus only requests basic identity scopes:
130+
131+
- `openid`
132+
- `email`
133+
- `profile`
134+
135+
## Google Smoke Test
136+
137+
The Google smoke test now validates a live Papyrus session produced by a successful Google browser login.
138+
139+
Recommended workflow:
140+
141+
1. Complete a real Google login in the auth sandbox.
142+
2. Copy the access token or refresh token from the sandbox.
143+
3. Run the smoke test against the running server.
144+
145+
Access-token-only mode:
146+
147+
```bash
148+
RUN_GOOGLE_SMOKE_TEST=true \
149+
AUTH_SMOKE_SERVER_BASE_URL=http://localhost:8080 \
150+
AUTH_SMOKE_GOOGLE_ACCESS_TOKEN=<access-token-from-sandbox> \
151+
uv run pytest tests/integration/test_auth_smoke.py -m auth_smoke -q
152+
```
153+
154+
Refresh-token mode is more durable and also validates token rotation:
155+
156+
```bash
157+
RUN_GOOGLE_SMOKE_TEST=true \
158+
AUTH_SMOKE_SERVER_BASE_URL=http://localhost:8080 \
159+
AUTH_SMOKE_GOOGLE_REFRESH_TOKEN=<refresh-token-from-sandbox> \
160+
uv run pytest tests/integration/test_auth_smoke.py -m auth_smoke -q
161+
```
162+
163+
If both are provided, the test tries the access token first and falls back to refresh if the access token is expired.
164+
165+
Notes:
166+
167+
- refresh-token mode rotates the provided refresh token, so the old token will stop working after the test
168+
- on success, the test prints `AUTH_SMOKE_ROTATED_REFRESH_TOKEN=...`; use that value for the next manual run
169+
- if you only provide an access token, the test is non-destructive but depends on that token still being unexpired
170+
- `AUTH_SMOKE_SERVER_BASE_URL` defaults to `PUBLIC_BASE_URL` if omitted

papyrus/api/routes/dev_auth_sandbox.py

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -349,12 +349,16 @@ def _sandbox_html(request: Request) -> str:
349349
}};
350350
351351
document.getElementById("google-login").onclick = async () => {{
352+
state.pendingOauthMode = "login";
353+
saveState();
352354
const url = new URL(config.googleStartUrl, window.location.origin);
353355
url.searchParams.set("redirect_uri", config.redirectUri);
354356
window.location.assign(url.toString());
355357
}};
356358
357359
document.getElementById("google-link").onclick = async () => {{
360+
state.pendingOauthMode = "link";
361+
saveState();
358362
const {{ body }} = await callApi(config.googleLinkStartUrl, {{
359363
method: "POST",
360364
body: JSON.stringify({{ redirect_uri: config.redirectUri }}),
@@ -437,19 +441,52 @@ def _sandbox_html(request: Request) -> str:
437441
saveState();
438442
}});
439443
440-
const params = new URLSearchParams(window.location.search);
441-
const code = params.get("code");
442-
const error = params.get("error");
443-
if (code) {{
444-
refs.exchangeCode.value = code;
445-
renderLastResponse({{ status: 302, body: {{ code }} }});
446-
history.replaceState(null, "", config.redirectUri);
447-
}} else if (error) {{
448-
renderLastResponse({{ status: 302, body: {{ error }} }});
449-
history.replaceState(null, "", config.redirectUri);
444+
async function handleOAuthReturn() {{
445+
const params = new URLSearchParams(window.location.search);
446+
const code = params.get("code");
447+
const error = params.get("error");
448+
449+
if (code) {{
450+
refs.exchangeCode.value = code;
451+
history.replaceState(null, "", config.redirectUri);
452+
453+
if (state.pendingOauthMode === "login") {{
454+
const {{ response, body }} = await callApi(config.exchangeUrl, {{
455+
method: "POST",
456+
body: JSON.stringify({{
457+
code,
458+
client_type: refs.clientType.value || "web",
459+
device_label: refs.deviceLabel.value || null,
460+
}}),
461+
}});
462+
463+
if (response.ok && body) {{
464+
setTokens(body);
465+
}}
466+
}} else if (state.pendingOauthMode === "link" && state.accessToken) {{
467+
await callApi(config.googleLinkCompleteUrl, {{
468+
method: "POST",
469+
body: JSON.stringify({{ code }}),
470+
}}, true);
471+
}} else {{
472+
renderLastResponse({{ status: 302, body: {{ code }} }});
473+
}}
474+
475+
delete state.pendingOauthMode;
476+
saveState();
477+
return;
478+
}}
479+
480+
if (error) {{
481+
renderLastResponse({{ status: 302, body: {{ error }} }});
482+
history.replaceState(null, "", config.redirectUri);
483+
delete state.pendingOauthMode;
484+
saveState();
485+
}}
450486
}}
451487
452488
setTokens(state);
489+
handleOAuthReturn();
453490
</script>
454491
</body>
455492
</html>"""

0 commit comments

Comments
 (0)