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
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
TS_DIR := typescript
PROJECTS := \
psu-request-generator \
psu-request-wizard \
psu-request-sender \
pfp-request-sender \
nhs-number-generator \
Expand Down
139 changes: 139 additions & 0 deletions apim_cli_helper/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# APIM CLI Helper

A lightweight CLI tool for obtaining NHS APIM authentication tokens using the `pytest-nhsd-apim` package. Designed to be called from TypeScript/Node.js projects via `child_process`.

## Installation

### Install with Poetry (Recommended)

```bash
cd apim_cli_helper
poetry install
```

This creates a virtual environment and installs the `apim-token` command.

### Make it globally accessible (for use from TypeScript)

```bash
poetry install
# The tool is now available as 'apim-token' when running in the poetry environment
# Or install it with pipx for global access:
pipx install .
```

## Usage

### JWT Authentication (Client Credentials)

```bash
export CLIENT_ID="your-client-id"
export JWT_KID="your-key-id"
export JWT_PRIVATE_KEY="$(cat path/to/private_key.pem)"

apim-token --auth-type jwt --environment int
```

### OAuth2 Authentication (Authorization Code)

```bash
export CLIENT_ID="your-client-id"
export CLIENT_SECRET="your-client-secret"
export USERNAME="test-user-id" # optional
export SCOPE="nhs-cis2" # optional

apim-token --auth-type oauth2 --environment int
```

## Output Formats

By default, outputs just the access token to stdout:
```bash
apim-token --auth-type jwt --environment int --output token
```

For the full token response (including expires_in, token_type, etc.):
```bash
apim-token --auth-type jwt --environment int --output full
```

## Usage from TypeScript

```typescript
import { execSync } from 'child_process';
import { readFileSync } from 'fs';

function getToken(authType: 'jwt' | 'oauth2', environment: string): string {
// Use poetry run to execute in the virtual environment
const result = execSync(
`poetry run apim-token --auth-type ${authType} --environment ${environment}`,
{
cwd: '/path/to/apim_cli_helper',
env: {
...process.env,
CLIENT_ID: 'your-client-id',
JWT_KID: 'your-key-id',
JWT_PRIVATE_KEY: readFileSync('private_key.pem', 'utf8'),
},
encoding: 'utf8'
}
);
return result.trim();
}

// Usage
const token = getToken('jwt', 'int');
console.log('Access token:', token);
```

### Alternative: Direct invocation (if installed with pipx)

If you've installed with `pipx install .`, you can call it directly:

```typescript
const result = execSync(
`apim-token --auth-type ${authType} --environment ${environment}`,
{ env: { ...process.env, CLIENT_ID: '...', ... }, encoding: 'utf8' }
);
```

## Environment Variables

### JWT Mode
- `CLIENT_ID` - (required) Client ID for the application
- `JWT_KID` - (required) Key ID for JWT signing
- `JWT_PRIVATE_KEY` - (required) Private key for JWT signing (PEM format)

### OAuth2 Mode
- `CLIENT_ID` - (required) Client ID for the application
- `CLIENT_SECRET` - (required) Client secret for the application
- `USERNAME` - (optional) Username for authentication (default: "test-user")
- `SCOPE` - (optional) OAuth2 scope (default: "nhs-cis2")
- `CALLBACK_URL` - (optional) OAuth2 callback URL (default: "https://google.com")

## Error Handling

Errors are output to stderr as JSON:
```json
{
"error": "Error message",
"type": "ValueError"
}
```

Exit codes:
- `0` - Success
- `1` - Error occurred

## Development

```bash
# Install dependencies
poetry install

# Run directly
poetry run apim-token --auth-type jwt --environment int

# Build package
poetry build
```
6 changes: 6 additions & 0 deletions apim_cli_helper/apim-token.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# Wrapper script to invoke apim-token from Poetry environment
# This allows TypeScript packages to call it without knowing the Poetry details

SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
cd "$SCRIPT_DIR" && poetry run apim-token "$@"
3 changes: 3 additions & 0 deletions apim_cli_helper/apim_cli_helper/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""APIM CLI Helper - Get authentication tokens for NHS APIM."""

__version__ = "0.1.0"
187 changes: 187 additions & 0 deletions apim_cli_helper/apim_cli_helper/get_token.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
#!/usr/bin/env python3
"""
CLI tool to get APIM authentication tokens.
Outputs JSON to stdout for consumption by TypeScript or other tools.
"""
import argparse
import json
import sys
from os import environ
from typing import Optional

from pytest_nhsd_apim.identity_service import (
AuthorizationCodeAuthenticator,
AuthorizationCodeConfig,
ClientCredentialsConfig,
ClientCredentialsAuthenticator,
)


def get_env_var(name: str, required: bool = True) -> Optional[str]:
"""Get environment variable with optional requirement check."""
value = environ.get(name)
if required and value is None:
raise ValueError(f"Required environment variable {name} is not set")
return value


def get_jwt_token(environment: str, client_id: str, jwt_kid: str, jwt_private_key: str) -> dict:
"""Get token using JWT (Client Credentials flow)."""
url = f"https://{environment.lower()}.api.service.nhs.uk/oauth2-mock"

config = ClientCredentialsConfig(
environment=environment.lower(),
identity_service_base_url=url,
client_id=client_id,
jwt_private_key=jwt_private_key,
jwt_kid=jwt_kid,
)

authenticator = ClientCredentialsAuthenticator(config=config)
token_response = authenticator.get_token()

if "access_token" not in token_response:
raise ValueError("Token response does not contain access_token")

return token_response


def get_oauth2_token(
environment: str,
client_id: str,
client_secret: str,
scope: str,
username: str,
callback_url: str = "https://google.com"
) -> dict:
"""Get token using OAuth2 (Authorization Code flow)."""
url = f"https://{environment.lower()}.api.service.nhs.uk/oauth2-mock"

login_form = {"username": username}

config = AuthorizationCodeConfig(
environment=environment.lower(),
identity_service_base_url=url,
callback_url=callback_url,
client_id=client_id,
client_secret=client_secret,
scope=scope,
login_form=login_form,
)

authenticator = AuthorizationCodeAuthenticator(config=config)
token_response = authenticator.get_token()

if "access_token" not in token_response:
raise ValueError("Token response does not contain access_token")

return token_response


def main():
parser = argparse.ArgumentParser(
description="Get APIM authentication tokens",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Environment Variables:
JWT mode:
CLIENT_ID - Client ID for the application
JWT_KID - Key ID for JWT signing
JWT_PRIVATE_KEY - Private key for JWT signing

OAuth2 mode:
CLIENT_ID - Client ID for the application
CLIENT_SECRET - Client secret for the application
USERNAME - Username for authentication (optional, default: test-user)
SCOPE - OAuth2 scope (optional, default: nhs-cis2)
CALLBACK_URL - OAuth2 callback URL (optional, default: https://google.com)

Examples:
# JWT authentication
export CLIENT_ID="my-client-id"
export JWT_KID="my-key-id"
export JWT_PRIVATE_KEY="$(cat private_key.pem)"
python get_token.py --auth-type jwt --environment int

# OAuth2 authentication
export CLIENT_ID="my-client-id"
export CLIENT_SECRET="my-client-secret"
export USERNAME="test-user"
python get_token.py --auth-type oauth2 --environment int
"""
)

parser.add_argument(
"--auth-type",
choices=["jwt", "oauth2"],
required=True,
help="Authentication type: jwt (Client Credentials) or oauth2 (Authorization Code)"
)

parser.add_argument(
"--environment",
required=True,
help="Environment (e.g., int, dev, ref, sandbox)"
)

parser.add_argument(
"--output",
choices=["token", "full"],
default="token",
help="Output format: 'token' for just the access token, 'full' for complete response"
)

args = parser.parse_args()

try:
if args.auth_type == "jwt":
# JWT authentication requires CLIENT_ID, JWT_KID, JWT_PRIVATE_KEY
client_id = get_env_var("CLIENT_ID")
jwt_kid = get_env_var("JWT_KID")
jwt_private_key = get_env_var("JWT_PRIVATE_KEY")

token_response = get_jwt_token(
environment=args.environment,
client_id=client_id,
jwt_kid=jwt_kid,
jwt_private_key=jwt_private_key
)

elif args.auth_type == "oauth2":
# OAuth2 authentication requires CLIENT_ID, CLIENT_SECRET
client_id = get_env_var("CLIENT_ID")
client_secret = get_env_var("CLIENT_SECRET")

# Optional parameters with defaults
username = get_env_var("USERNAME", required=False) or "test-user"
scope = get_env_var("SCOPE", required=False) or "nhs-cis2"
callback_url = get_env_var("CALLBACK_URL", required=False) or "https://google.com"

token_response = get_oauth2_token(
environment=args.environment,
client_id=client_id,
client_secret=client_secret,
scope=scope,
username=username,
callback_url=callback_url
)

# Output to stdout
if args.output == "token":
print(token_response["access_token"])
else:
print(json.dumps(token_response, indent=2))

return 0

except Exception as e:
error_output = {
"error": str(e),
"type": type(e).__name__
}
print(json.dumps(error_output), file=sys.stderr)
return 1


if __name__ == "__main__":
sys.exit(main())
Loading