Metadata-Version: 2.4
Name: gated-access
Version: 1.1.0
Summary: Pluggable action-gated access for any stack: verify an action, email a one-time code, issue a signed JWT.
Project-URL: Homepage, https://github.com/NI3singh/gated-access
Project-URL: Issues, https://github.com/NI3singh/gated-access/issues
Author: Gated Access contributors
License-Expression: MIT
License-File: LICENSE
Keywords: access,auth,fastapi,gate,jwt,oauth,one-time-code
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: FastAPI
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Topic :: Security
Requires-Python: >=3.10
Requires-Dist: fastapi>=0.110
Requires-Dist: httpx>=0.27
Requires-Dist: itsdangerous>=2.1
Requires-Dist: jinja2>=3.1
Requires-Dist: pydantic>=2.6
Requires-Dist: pyjwt>=2.7
Requires-Dist: python-multipart>=0.0.9
Requires-Dist: pyyaml>=6.0
Requires-Dist: uvicorn[standard]>=0.29
Provides-Extra: dev
Requires-Dist: build; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: twine; extra == 'dev'
Description-Content-Type: text/markdown

<div align="center">

<img src="https://raw.githubusercontent.com/NI3singh/gated-access/main/assets/logo.svg" width="92" alt="Gated Access logo"/>

# Gated Access

**Pluggable action-gated access for any stack.**

Require a verifiable action — star a repo, follow a user, or your own custom check —
then email a one-time code and issue a signed access token.

<sub>FastAPI · zero-setup local demo · swap an adapter, not your project</sub>

</div>

---

## The idea

Plenty of products want to gate access behind an action: *"do this, then you're in."*
The fragile way is to hard-wire that one action into your app. When the platform or the
rules change, you rewrite everything.

Gated Access treats the **action as a swappable adapter**. The engine only knows the flow:

```
  ┌──────────┐    ┌───────────┐    ┌──────────────┐    ┌─────────────┐
  │ Identify │ -> │  Action   │ -> │  One-time    │ -> │   Access    │
  │ (OAuth)  │    │ (verify)  │    │  code (mail) │    │   (JWT)     │
  └──────────┘    └───────────┘    └──────────────┘    └─────────────┘
```

The user signs in, Gated Access checks the action against their *real* account, emails a
single-use code, and exchanges that code for a JWT your app can verify with a shared
secret. Identity, verification, email, and storage are each an adapter — change one and
nothing else moves.

> **"Any stack"** means the engine is one small headless HTTP API plus a drop-in frontend.
> Your app can be anything; it just talks to four endpoints (or verifies the JWT directly).

---

## Quickstart — the local demo

No GitHub app, no email provider, no credit card. It runs entirely on your machine.

```bash
pip install gated-access
gated-access run        # open http://localhost:8000
```

Click through all four steps. In demo mode Gated Access:

- **simulates** the GitHub sign-in (no real OAuth needed),
- lets you **toggle** "I did the action" to flip what the verifier sees,
- prints the email to the console **and** shows it in an on-screen *demo inbox* so you can
  read the code without sending real mail.

---

## CLI

| Command | What it does |
|---|---|
| `gated-access run [--host H] [--port P] [--config PATH]` | Start the server. Host/port default to the config values; `--config` points at an alternate `gated-access.yaml`. Refuses to start on blocking config errors. |
| `gated-access check [--config PATH]` | Config doctor: reports missing secrets/credentials, bad adapter settings, and risky production choices before you deploy. Exit 1 on blocking problems. |
| `gated-access secrets` | Print ready-to-paste `GATED_ACCESS_JWT_SECRET=` / `GATED_ACCESS_SESSION_SECRET=` lines (cryptographically random). |
| `gated-access init [--force]` | Interactively write a commented `gated-access.yaml` in the current directory. Pressing Enter at every prompt yields the demo config; non-demo choices print the env vars you still need to set. |
| `gated-access version` | Print the installed version. |

`python -m gated_access` also starts the server, and `uvicorn gated_access.main:app` remains the
plain ASGI entrypoint for deployment.

---

## How it fits together

Four adapter families, each behind a tiny interface in `gated_access/adapters/base.py`:

| Adapter | Demo default | Production options |
|---|---|---|
| **Identity** — who is this user? | `demo` (fabricated user) | `github` (OAuth web flow) |
| **Verification** — did they do it? | `manual` (simulated) | `github_star`, `github_follow` |
| **Email** — deliver the code | `console` (+ demo inbox) | `resend`, `smtp` |
| **Storage** — sessions & codes | `memory` | `sqlite` |

Adding a new check (a webhook, an on-chain event, "is a paying customer") is just a new
`VerificationAdapter` subclass — the routes, codes, tokens, and UI stay exactly the same.

---

## Configuration

Everything is driven by `gated-access.yaml`, with **environment variables overriding the file**
so secrets never live in config. Common keys:

| Key | Meaning |
|---|---|
| `identity.provider` | `demo` or `github` |
| `verify.type` | `manual` · `github_star` (needs `verify.repo`) · `github_follow` (needs `verify.username`) |
| `email.provider` | `console` · `resend` · `smtp` |
| `storage.backend` | `memory` · `sqlite` |
| `code.length` / `code.ttl_seconds` / `code.single_use` | one-time code policy |
| `access_ttl_seconds` | JWT lifetime |
| `demo_mode` | enables the simulated login + `/dev/*` endpoints — **set `false` in production** |
| `branding.*` | product name, copy, and accent colour shown in the UI |

See `.env.example` for the full list of secret/deploy variables.

---

## HTTP API

The frontend is optional — these are all you need to integrate from any language.

| Method & path | Purpose |
|---|---|
| `GET /auth/login` | Begin sign-in (redirects to the identity provider) |
| `GET /auth/callback` | Finish sign-in; sets a signed, http-only session cookie |
| `GET /api/session` | Current status: authenticated? action done? verified? |
| `POST /api/verify` | Run the verification adapter; on success, email a one-time code |
| `POST /api/redeem` | Body `{"code": "..."}` → `{"access": true, "token": "<JWT>"}` |
| `GET /api/me` | Example protected resource — requires `Authorization: Bearer <JWT>` |
| `GET /healthz` | Liveness check |
| `POST /dev/perform-action`, `GET /dev/inbox` | Demo-only helpers (disabled when `demo_mode: false`) |

Your backend doesn't even have to call `/api/me` — verify the JWT yourself with the shared
`jwt_secret` (HS256, issuer `gated-access`).

---

## Going to production

1. **Generate secrets** and set them in the environment:
   ```bash
   gated-access secrets    # prints GATED_ACCESS_JWT_SECRET=... and GATED_ACCESS_SESSION_SECRET=...
   ```
   Gated Access refuses to start with `demo_mode: false` while the dev placeholder secrets
   are still in place.
2. **Create a GitHub OAuth app** at <https://github.com/settings/developers>. Set the
   callback URL to `https://your-domain/auth/callback` and export `GITHUB_CLIENT_ID`,
   `GITHUB_CLIENT_SECRET`, `GITHUB_REDIRECT_URI`.
3. **Switch the adapters** in `gated-access.yaml`:
   ```yaml
   demo_mode: false
   identity: { provider: github }
   verify:   { type: github_star, repo: "you/your-repo" }
   email:    { provider: resend }       # set RESEND_API_KEY in env
   storage:  { backend: sqlite }
   ```
4. **Serve it** behind HTTPS (the session cookie is marked `Secure` automatically when
   `base_url` is `https`):
   ```bash
   uvicorn gated_access.main:app --host 0.0.0.0 --port 8000
   ```

Because it's a plain ASGI app with no required cloud services, it deploys anywhere that
runs Python — a container, Render, Railway, Fly, a VM, etc.

---

## Embedding in front of another app

Gated Access is a standalone gate, not a library you paste into your app. To gate a
feature in *your* product (e.g. "only users who starred my repo can use this"), run the
gate on its own subdomain and integrate at two points in your own code.

**1. Run the gate** at e.g. `gate.yoursite.com` with `identity: github`,
`verify: { type: github_star, repo: "you/repo" }`, a real email provider, `sqlite`
storage, and set **`app_return_url`** (or `GATED_ACCESS_APP_RETURN_URL`) to a URL on your
app. After a user passes the gate, the success page shows a **"Return to your app →"**
button that sends the browser to:

```
https://yoursite.com/unlock-return#ga_token=<JWT>
```

The token rides in the URL **fragment**, so it never reaches servers or referrers.

**2. Consume it in your app.** Your front end reads the fragment and posts it to your
backend; your backend verifies the JWT with the **same secret** the gate signs with
(`GATED_ACCESS_JWT_SECRET`) and records the user as unlocked:

```python
from gated_access.tokens import verify_access_token   # same HS256 logic the gate uses

claims = verify_access_token(SHARED_SECRET, token)    # checks signature, expiry, issuer
if not claims:
    raise Unauthorized()
mark_unlocked(current_user.id, github_sub=claims["sub"])   # persist in YOUR db
```

Then gate your feature on that stored flag. Because the JWT is signed and stateless, your
app verifies it without calling the gate. To test the consume side without OAuth, mint a
token directly with `issue_access_token(SHARED_SECRET, subject="123", username="me",
ttl_seconds=3600)`.

A complete runnable host app (unlock-return page, `/unlock` endpoint, gated route) lives
in [`examples/host_app.py`](https://github.com/NI3singh/gated-access/blob/main/examples/host_app.py).

---

## Security notes

- **One-time codes** drawn from an unambiguous alphabet (no `0/O/1/I/L`), single-use and
  time-limited, and **bound to the session that earned them** — a code minted for one user
  is rejected for another.
- **JWT access tokens** (HS256) with issuer and expiry; verify with your shared secret.
- **OAuth `state`** is single-use and short-lived (CSRF protection); the session cookie is
  signed (`itsdangerous`), http-only, and `Secure` over HTTPS.
- The **provider access token is held only until verification succeeds**, then dropped.
- **`/api/redeem` is rate-limited** per session + IP to blunt code-guessing.

---

## A note on GitHub's rules

GitHub's Acceptable Use Policies prohibit **incentivizing stars** ("inauthentic
interactions" and giving stars in exchange for any benefit). So a real "star *my* repo to
unlock" gate aimed at the public would violate those terms.

This project is built as a **portfolio / reference demo of the pattern**, not a growth
hack. Keep it on the right side of the line by pointing the verifier at your *own* repo for
testing, or by using a check that isn't incentivized starring — `github_follow`, a webhook,
"is a customer", or any custom `VerificationAdapter` you write. The whole point of the
adapter design is that the gate survives exactly this kind of rule change.

---

## Project layout

```
gated_access/
├── gated_access/
│   ├── adapters/        identity · verification · email · storage (+ base interfaces)
│   ├── service.py       the engine: begin_auth → verify → redeem
│   ├── factory.py       builds adapters from config and wires the FastAPI routes
│   ├── security.py      signed cookies, OAuth state, rate limiter
│   ├── tokens.py        one-time codes + JWT
│   ├── config.py        typed config (YAML + env)
│   ├── cli.py           the `gated-access` command (run · check · secrets · init · version)
│   ├── _version.py      single source of truth for the version
│   ├── templates/       demo UI
│   └── static/          styles, client logic, logo
├── examples/            runnable host app showing the consume side
├── tests/               token unit tests + full end-to-end flow
├── pyproject.toml       packaging (hatchling) — the source of truth for dependencies
├── gated-access.yaml         configuration (demo defaults)
├── .env.example         production secrets template
└── requirements*.txt    convenience for clone-based development
```

---

## Development

```bash
git clone https://github.com/NI3singh/gated-access && cd gated-access
pip install -e .[dev]
pytest -q
gated-access run            # or: python -m gated_access
```

---

## Publishing (maintainers)

```bash
python -m build                                # dist/gated-access-X.Y.Z.tar.gz + .whl
twine check dist/*
twine upload --repository testpypi dist/*      # dress rehearsal
pip install -i https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple gated-access
twine upload dist/*                            # real release (needs a PyPI API token)
```

The version lives in `gated_access/_version.py` only — bump it there.

---

## License

MIT — see [LICENSE](https://github.com/NI3singh/gated-access/blob/main/LICENSE).
