Skip to content
Open
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
71 changes: 71 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
name: ci

on:
pull_request:
push:
branches:
- main

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# The floor the package declares, and the newest supported release.
python-version: ["3.11", "3.13"]
# 3.10 is the declared floor but fastmcp needs 3.11+, so the floor is
# verified by the core-extra-only job below.
steps:
- uses: actions/checkout@v6

- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}

- name: Install with the fastmcp extra
run: |
python -m pip install --upgrade pip
python -m pip install -e ".[fastmcp]" pytest pytest-asyncio

- name: Test
run: python -m pytest tests/ -q

core-extra-only:
# The framework-agnostic core must import and work with no MCP framework
# installed, so `namoid[mcp]` stays usable outside FastMCP. Also runs on the
# declared minimum Python, which fastmcp itself does not support.
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.13"]
steps:
- uses: actions/checkout@v6

- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}

- name: Install with the mcp extra only
run: |
python -m pip install --upgrade pip
python -m pip install -e ".[mcp]" pytest pytest-asyncio

- name: Core, Hosted Auth, and modularity tests
run: >-
python -m pytest tests/test_mcp_authorization.py tests/test_hosted_auth.py
tests/test_modularity.py -q

- name: Assert fastmcp is absent
run: |
python - <<'PY'
import importlib.util, sys
if importlib.util.find_spec("fastmcp") is not None:
sys.exit("fastmcp must not be installed by the mcp extra")
import namoid.mcp
print("core imports without an MCP framework")
PY
233 changes: 228 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,235 @@ Python SDK for [NamoID](https://namoid.in) — enterprise identity for India (OA
pip install namoid
```

## Usage
Hosted Auth needs only the base install. Protecting an MCP server adds an extra:

See the integration guides and API reference at
[docs.namoid.in](https://docs.namoid.in), and runnable end-to-end examples in
[`namoid-examples`](https://github.com/namoidhq/namoid-examples)
(`python-fastapi`, `python-flask`).
```bash
pip install "namoid[fastmcp]" # protect a FastMCP server (Python 3.11+)
pip install "namoid[mcp]" # the same core, without FastMCP
```

Python 3.10 or newer. The FastMCP extra requires 3.11.

## Pick what you need

The two surfaces are independent. Take either, both, or neither — you pay only
for what you name.

| You want | Install | Import | Pulled in |
|---|---|---|---|
| Hosted Auth | `namoid` | `namoid` | `httpx` |
| Protect an MCP server | `namoid[mcp]` | `namoid.mcp` | `httpx`, `joserfc` |
| …on FastMCP | `namoid[fastmcp]` | `namoid.mcp.fastmcp` | the above, `fastmcp` |

`import namoid` loads neither surface. The top-level names resolve on first use
(PEP 562), so:

- A Hosted Auth application never imports the MCP code and never needs its extra.
- An MCP server never imports the Hosted Auth client.
- The MCP core (`namoid.mcp`) imports no MCP framework at all — `fastmcp` appears
only inside `namoid.mcp.fastmcp`, so the core also backs the official MCP
Python SDK, a bare Starlette app, or a test.

`dir(namoid)` and `namoid.__all__` still list the full surface, and type checkers
resolve every export, so laziness costs nothing in editor support. These
boundaries are enforced by tests in `tests/test_modularity.py`, each run in a
fresh interpreter, rather than left as an intention.

Everything raises `NamoIDError`, including the MCP errors, so one `except`
catches both surfaces without importing the one you do not use.

## Hosted Auth

Hosted Auth redirects the user to a branded NamoID sign-in page and returns a
one-time code. The Client ID resolves the application, its environment, and its
Hosted Auth domain, so there is no issuer or application UUID to configure.

```python
from namoid import NamoIDClient

namoid = NamoIDClient(
client_id=os.environ["NAMOID_CLIENT_ID"],
client_secret=os.environ["NAMOID_CLIENT_SECRET"], # server-side only
)

# 1. Start a state-bound transaction and keep the verifier in the user's session.
transaction = namoid.create_transaction()
session["namoid_state"] = transaction.state
session["namoid_verifier"] = transaction.code_verifier

# 2. Send the browser to the application's own hosted sign-in page.
url = namoid.hosted_auth_url(
return_to="https://app.example/auth/callback",
state=transaction.state,
completion_mode="confidential",
code_challenge=transaction.code_challenge,
)

# 3. On the callback, compare state, then exchange the code on the server.
tokens = namoid.exchange_code(
code=request.args["code"],
code_verifier=session.pop("namoid_verifier"),
)

# 4. Confirm the token and create your own application session.
result = namoid.validate_access_token(tokens.access_token)
if not result.valid:
raise Unauthorized()

# 5. On sign-out, revoke the NamoID session too.
namoid.revoke_session(access_token=tokens.access_token, refresh_token=tokens.refresh_token)
```

`AsyncNamoIDClient` has exactly the same methods with `await`, for FastAPI,
Starlette, or any async framework:

```python
from namoid import AsyncNamoIDClient

async with AsyncNamoIDClient(client_id=..., client_secret=...) as namoid:
tokens = await namoid.exchange_code(code=code, code_verifier=verifier)
```

Both accept an `http_client` if you want to supply your own configured
`httpx.Client` / `httpx.AsyncClient`, and cache the auth config after the first
fetch.

For a browser-only public client, redirect with `completion_mode="public"` and
exchange with `confidential=False` — PKCE protects the flow and no secret is
involved. Never put a Client Secret anywhere a browser can reach.

| Method | Endpoint |
|---|---|
| `get_auth_config()` | `GET /v1/auth/config` |
| `hosted_auth_url(...)` | builds the URL, no request |
| `exchange_code(...)` | `POST /v1/auth/hosted/exchange` |
| `refresh(...)` | `POST /v1/auth/refresh` |
| `validate_access_token(...)` | `POST /v1/auth/tokens/validate` |
| `revoke_session(...)` | `POST /v1/auth/logout` |

Every failure raises `NamoIDError`, carrying `status`, `code` (the API's own
error code when present), and the parsed `detail`.

`namoid.hosted_auth` exposes the pure pieces — `create_hosted_auth_transaction`,
`build_hosted_auth_url`, `build_configured_hosted_auth_url`, `pkce_challenge`,
`random_base64url` — if you would rather drive the flow yourself.

## Protect an MCP server

NamoID is the authorization server. Your MCP server is the protected resource.
An MCP host — Claude, ChatGPT, Cursor, VS Code — is the OAuth client, and the
signed-in human is the resource owner. NamoID authenticates that human, records
consent, and issues a short-lived token limited to your server and to the
actions approved; this package validates and enforces it.

No NamoID credentials are needed: a resource server only consumes public
discovery metadata and JWKS.

```python
from fastmcp import FastMCP
from namoid.mcp.fastmcp import create_namoid_auth, current_caller, require_namoid_scopes

# Console -> Environment -> MCP Authorization -> Integration details.
# Discovery runs here, so a wrong issuer or resource fails at startup rather
# than as an opaque 401 on the first tool call.
auth = create_namoid_auth(
issuer="https://acme-test.id.namoid.in",
resource="https://mcp.acme.example/mcp", # exactly as registered as the audience
resource_name="Acme Finance MCP",
scopes_supported=["customers:read", "invoices:read"],
)

mcp = FastMCP(name="acme-finance-mcp", auth=auth.provider)


@mcp.tool
@require_namoid_scopes(auth, "refunds:create")
def issue_refund(invoice_id: str, amount_minor: int) -> dict:
caller = current_caller(auth) # the NamoID user who consented
assert_refund_allowed(caller.subject, invoice_id, amount_minor)
return refund(invoice_id, amount_minor)


if __name__ == "__main__":
# The path comes from the resource URL, so the endpoint matches the audience.
mcp.run(transport="http", path=auth.mcp_path)
```

A scope is permission to **attempt** an action. `refunds:create` does not mean
this user may refund another organization's invoice or exceed your refund
policy. Ownership, limits, and every other business rule stay in the handler.

When a scope is missing, the tool answers with an `insufficient_scope` result
naming the missing scopes, the resource, and the metadata URL — what a host
needs to start incremental authorization. The tool stays visible in
`tools/list`, because hiding it would leave the host unable to ask for access.
Use FastMCP's own `require_scopes` when hiding a capability is the goal.

### What it validates

Every token must satisfy all of:

| Check | Why |
|---|---|
| `RS256` from the environment's JWKS | The only algorithm NamoID issues |
| Exact `iss` | A token from another issuer is not yours |
| Exact `aud` | A token minted for MCP server A must fail on server B |
| `exp` / `nbf`, 30s tolerance | Configurable via `clock_tolerance_seconds` |
| `token_use == "access"` | An ID token must never be an API token |
| `sub` and `client_id` present | Without `sub`, every caller is one identity |

Discovery also checks that the issuer's metadata declares the issuer you asked
for — RFC 9700 mix-up defence — and reads `jwks_uri` from it rather than
hard-coding a key location. The key set is cached, and refetched when a token
arrives with an unrecognised `kid` so key rotation is picked up without a
restart.

### Without FastMCP

`namoid.mcp` imports no MCP framework, so it can back the official MCP Python
SDK, a bare Starlette app, or a test:

```python
from namoid.mcp import create_namoid_mcp_auth, NamoIDMcpTokenError

auth = await create_namoid_mcp_auth(
issuer="https://acme-test.id.namoid.in",
resource="https://mcp.acme.example/mcp",
scopes_supported=["invoices:read"],
)

# Publish auth.protected_resource_metadata at auth.metadata_path (RFC 9728),
# and answer an unauthenticated call with a WWW-Authenticate challenge
# pointing at auth.metadata_url.
try:
caller = await auth.verify_access_token(bearer_token)
except NamoIDMcpTokenError:
... # 401 + challenge
```

`create_namoid_mcp_auth_sync` is the blocking form, for servers built at module
import where there is no event loop to await on.

### Client onboarding

NamoID resolves MCP clients by pre-registration or Client ID Metadata Document
(CIMD). Dynamic Client Registration is not available for customer-owned MCP
resources, so a host that can only do DCR cannot connect yet.

## Examples

Complete runnable servers, including a TypeScript equivalent:
[namoid-examples/mcp-authorization](https://github.com/namoidhq/namoid-examples/tree/main/mcp-authorization).

For the rest of the SDK, see the integration guides and API reference at
[docs.namoid.in](https://docs.namoid.in).

## Develop

```bash
python -m venv .venv && .venv/bin/pip install -e ".[fastmcp]" pytest pytest-asyncio
.venv/bin/python -m pytest tests/ -q
```

## Links

Expand Down
35 changes: 33 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,17 @@ build-backend = "hatchling.build"

[project]
name = "namoid"
version = "0.0.2"
version = "0.1.0"
description = "Python SDK for NamoID, enterprise identity for India (OAuth 2.1 / OIDC)."
readme = "README.md"
requires-python = ">=3.9"
requires-python = ">=3.10"
license = "MIT"
license-files = ["LICENSE"]
authors = [{ name = "PolyMindsLabs Pvt. Ltd.", email = "hello@namoid.in" }]
keywords = [
"namoid",
"mcp",
"model-context-protocol",
"authentication",
"oauth",
"oauth2",
Expand All @@ -32,10 +34,31 @@ classifiers = [
"Intended Audience :: Developers",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Security",
"Topic :: Software Development :: Libraries",
]

dependencies = [
"httpx>=0.28",
]

[project.optional-dependencies]
# Protect an MCP server. Framework-agnostic core: discovery, audience-bound
# token verification, and RFC 9728 metadata.
mcp = [
"joserfc>=1.0",
]
# The same core wired into FastMCP. `fastmcp` itself requires a newer Python
# than this package's floor, so pip enforces that when the extra is installed.
fastmcp = [
"joserfc>=1.0",
"fastmcp>=3.4.5,<4",
]

[project.urls]
Homepage = "https://namoid.in"
Documentation = "https://namoid.in"
Expand All @@ -44,3 +67,11 @@ Issues = "https://github.com/namoidhq/namoid-python/issues"

[tool.hatch.build.targets.wheel]
packages = ["src/namoid"]

[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]

[tool.ruff]
line-length = 100
target-version = "py310"
Loading