diff --git a/README.md b/README.md index cceea15..17e3170 100644 --- a/README.md +++ b/README.md @@ -1,41 +1,36 @@ # HMS AI Inference Service -This service lets HMS users send prompts to a private, HMS-hosted AI model. You -log in with your HMS ID to get a temporary access token, then send requests to a -secure gateway that checks your identity and usage limits before passing them to -the model. - -**In technical terms:** a GPU inference service that exposes an on-prem large -language model (LLM) to HMS users through a cloud API gateway. The gateway -speaks both the **Anthropic Messages API** and the **OpenAI API**, so you can -call it from Anthropic- or OpenAI-based clients (Claude Code, OpenAI SDKs, -LiteLLM, etc.) without changing tooling. The model is served by **vLLM** on an -**NVIDIA Grace Hopper GH200**. *On-prem* means it runs on HMS's own hardware, -not in the cloud. Access is secured with Okta-issued JWTs (signed, short-lived -access tokens). - -## Contents - -- [TL;DR quickstart](#tldr-quickstart) -- [How it works](#how-it-works) -- [1. Acquire a token](#1-acquire-a-token) -- [2. Make a test call with curl](#2-make-a-test-call-with-curl) - - [OpenAI-compatible API](#openai-compatible-api) -- [3. Use the endpoint with Claude Code](#3-use-the-endpoint-with-claude-code) - - [Option A — quick start](#option-a--quick-start-static-token-via-environment) - - [Option B — recommended: auto-refreshing token](#option-b--recommended-auto-refreshing-token-via-apikeyhelper) -- [Keeping the token fresh](#keeping-the-token-fresh) -- [Troubleshooting](#troubleshooting) - -## TL;DR quickstart - -> **Before you start:** -> - **Username ≠ email.** Your HMS username isn't your HMS email — find it under -> [login.hms.harvard.edu → Profile](https://login.hms.harvard.edu/account-settings/profile). -> - **VPN required.** You must be connected to the HMS VPN to reach the endpoint. +Send prompts to a private, HMS-hosted AI model. You log in with your HMS ID to +get a short-lived access token, then call a secure gateway that checks your +identity and usage quota before forwarding the request to the model. + +Under the hood: an on-prem large language model served by **vLLM** on an +**NVIDIA Grace Hopper GH200**, exposed through **Azure API Management**. The +gateway speaks both the **Anthropic Messages API** and the **OpenAI API**, so +Anthropic- and OpenAI-based clients (Claude Code, OpenAI SDKs, LiteLLM, …) work +without changing tooling. *On-prem* means HMS's own hardware, not the cloud. + +## At a glance + +| | | +| ------------ | ---------------------------------------------------------------------------------------------- | +| **Base URL** | `https://ai-poc.hms.edu` | +| **Model ID** | `google/gemma-4-31B-it` | +| **Anthropic**| `POST /v1/messages` | +| **OpenAI** | `POST /v1/chat/completions`, `GET /v1/models` | +| **Auth** | `Authorization: Bearer ` — short-lived, refreshable | +| **Needs** | HMS VPN, `curl`, `jq` | + +## Before you start + +- **You must be on the HMS VPN.** The endpoint is unreachable otherwise. +- **Your HMS username is not your email.** Find it under + [login.hms.harvard.edu → Profile](https://login.hms.harvard.edu/account-settings/profile). + +## Quickstart ```bash -# 1. Get a token (interactive — prompts for your HMS username + password) +# 1. Get a token (prompts for HMS username + password) export HMS_AI_TOKEN="$(./get-okta-token.sh | awk -F'Token: ' '/Token:/{print $2}')" # 2. Test the endpoint @@ -46,28 +41,26 @@ curl --silent https://ai-poc.hms.edu/v1/messages \ --data '{"model":"google/gemma-4-31B-it","max_tokens":256, "messages":[{"role":"user","content":"Say hello in one sentence."}]}' -# 3. Point your application at it, e.g. for Claude Code: +# 3. Point a client at it — Claude Code, for example export ANTHROPIC_BASE_URL=https://ai-poc.hms.edu +export ANTHROPIC_AUTH_TOKEN="$HMS_AI_TOKEN" export ANTHROPIC_DEFAULT_OPUS_MODEL=google/gemma-4-31B-it export ANTHROPIC_DEFAULT_SONNET_MODEL=google/gemma-4-31B-it export ANTHROPIC_DEFAULT_HAIKU_MODEL=google/gemma-4-31B-it -export ANTHROPIC_AUTH_TOKEN="$HMS_AI_TOKEN" claude ``` -Token expired? Re-run step 1. For an auto-refreshing setup, see -[Option B](#option-b--recommended-auto-refreshing-token-via-apikeyhelper). +Token expired? Re-run step 1 — or set up +[automatic refresh](#step-3--point-claude-code-at-the-endpoint), which is worth +the five minutes if you use this daily. --- ## How it works -You never talk to the on-prem model directly — it sits inside the **HMS On-Prem -Trust Boundary** with no external access. Instead, every request flows through -**Azure API Management (APIM)**, a cloud gateway that authenticates and -rate-limits you before forwarding the request to the backend. Both the request -and the response pass through the gateway; a client never reaches the backend -directly. +You never talk to the model directly. It sits inside the HMS on-prem trust +boundary with no external access, so every request goes through the Azure API +Management (APIM) gateway — request and response both. ```mermaid flowchart LR @@ -94,24 +87,19 @@ flowchart LR APIM -- "10. Response (200)" --> User ``` -In short, three phases: +Three phases, and the failure of each is the error code you'll see: -1. **Authenticate (AuthN).** You log in to Okta with your HMS credentials and - receive a JWT access token. -2. **Authorize (AuthZ).** You call the APIM gateway with the JWT. APIM validates - the token against Okta and checks that you are within your LLM quota. - - No or invalid token → `401` / `403` - - Over quota → `429` -3. **Inference.** On success, APIM forwards your request to the on-prem model and - returns the response. +| Phase | What happens | Failure | +| --- | --- | --- | +| **Authenticate** | You log in to Okta, which issues a JWT access token | Bad credentials → error from the token endpoint | +| **Authorize** | APIM validates the JWT against Okta and checks your quota | Missing/invalid token → `401`/`403`; over quota → `429` | +| **Inference** | APIM forwards to the on-prem model and returns its reply | — | --- -## 1. Acquire a token +## Step 1 — Get a token -The service uses the OAuth 2.0 **Resource Owner Password** grant against Okta. -A helper script, [`get-okta-token.sh`](./get-okta-token.sh), is included for -interactive use: +Run the included helper, [`get-okta-token.sh`](./get-okta-token.sh): ```bash ./get-okta-token.sh @@ -120,7 +108,20 @@ interactive use: # Token: eyJraWQiOi... ``` -Or call the token endpoint directly: +It prints a labelled line, so capture the token with `awk`: + +```bash +export HMS_AI_TOKEN="$(./get-okta-token.sh | awk -F'Token: ' '/Token:/{print $2}')" +``` + +Add `-r`/`--refresh` to get a **refresh token** instead — a longer-lived +credential that mints access tokens without your password. See +[Refresh tokens](#refresh-tokens). + +
+Calling the Okta token endpoint yourself + +The service uses the OAuth 2.0 Resource Owner Password grant: ```bash curl --silent --request POST \ @@ -135,38 +136,23 @@ curl --silent --request POST \ | jq -r '.access_token' ``` -To keep the token handy for the calls below, capture it in an environment -variable: - -```bash -export HMS_AI_TOKEN="$(./get-okta-token.sh | awk -F'Token: ' '/Token:/{print $2}')" -# or, from the raw curl above: -# export HMS_AI_TOKEN="$(curl ... | jq -r '.access_token')" -``` +Note that Okta returns HTTP 200 even for bad credentials — check for an +`access_token` in the body rather than trusting the status code. -**Token lifetime.** Access tokens are short-lived, so you'll need to refresh -them. The `offline_access` scope also returns a `refresh_token` that mints new -access tokens without re-entering your password — see -[Keeping the token fresh](#keeping-the-token-fresh). +
-**Handle your credentials carefully.** Don't hard-code your password in scripts -or commit tokens to source control. Prefer environment variables or a secrets -manager. +> **Credentials.** Access tokens are short-lived by design. Don't hard-code your +> password in scripts or commit tokens to source control; use environment +> variables or a secrets manager. --- -## 2. Make a test call with curl +## Step 2 — Make a test call -The service speaks two API dialects over the same gateway, so you can use -whichever your tools already support: +Both API dialects hit the same vLLM backend through the same Okta and quota +checks — only the request shape differs. Use whichever your tools already speak. -- **Anthropic Messages API** (`POST /v1/messages`) — used below and by Claude Code. -- **OpenAI API** (`POST /v1/chat/completions`) — for OpenAI SDKs and tools; see - [OpenAI-compatible API](#openai-compatible-api). - -Both hit the same vLLM backend and the same Okta/quota checks; only the request -shape differs. We'll start with the Anthropic Messages API — send requests to -`POST /v1/messages` with your JWT as a Bearer token: +### Anthropic Messages API ```bash curl --silent https://ai-poc.hms.edu/v1/messages \ @@ -182,11 +168,8 @@ curl --silent https://ai-poc.hms.edu/v1/messages \ }' ``` -A successful call (`200`) returns a JSON body with the model's reply. The common -failures — `401`/`403` (bad or expired token) and `429` (over quota) — map to the -three phases above; see [Troubleshooting](#troubleshooting) for the full list. - -Quick way to check auth is working without a full inference call: +A `200` returns the model's reply as JSON. To check auth alone, without waiting +on inference, print just the status code: ```bash curl --silent -o /dev/null -w "%{http_code}\n" https://ai-poc.hms.edu/v1/messages \ @@ -196,12 +179,11 @@ curl --silent -o /dev/null -w "%{http_code}\n" https://ai-poc.hms.edu/v1/message --data '{"model":"google/gemma-4-31B-it","max_tokens":1,"messages":[{"role":"user","content":"hi"}]}' ``` -### OpenAI-compatible API +### OpenAI API -The backend is vLLM, which also serves the **OpenAI API** natively. If the -gateway exposes the OpenAI routes (confirm with the platform team), you can use -`POST /v1/chat/completions` with the same Bearer token — handy for OpenAI SDKs -and tools like LiteLLM, Continue, or Aider: +vLLM serves the OpenAI API natively, and the gateway exposes those routes. Use +the same Bearer token — handy for OpenAI SDKs and tools like LiteLLM, Continue, +or Aider: ```bash curl --silent https://ai-poc.hms.edu/v1/chat/completions \ @@ -215,77 +197,81 @@ curl --silent https://ai-poc.hms.edu/v1/chat/completions \ }' ``` -List the models the backend advertises: +To list the models the backend advertises: ```bash curl --silent https://ai-poc.hms.edu/v1/models \ --header "Authorization: Bearer $HMS_AI_TOKEN" | jq . ``` -Rule of thumb: use the **Anthropic Messages API** for Claude Code, and the -**OpenAI API** for OpenAI-based clients. - --- -## 3. Use the endpoint with Claude Code +## Step 3 — Point Claude Code at the endpoint -Claude Code can point at any Anthropic-compatible gateway: it sends requests to -`ANTHROPIC_BASE_URL` + `/v1/messages` with your JWT as the bearer token. It also -asks for three model "tiers" (Opus / Sonnet / Haiku) depending on the task, but -the gateway serves a single vLLM model — so you point all three tiers -(`ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, -`ANTHROPIC_DEFAULT_HAIKU_MODEL`) at `google/gemma-4-31B-it`, and every request -resolves to it regardless of which tier Claude Code picks. +Claude Code works with any Anthropic-compatible gateway: it POSTs to +`ANTHROPIC_BASE_URL` + `/v1/messages` with a bearer token. It requests three +model tiers (Opus / Sonnet / Haiku) depending on the task, but the gateway serves +one model — so point all three tiers at `google/gemma-4-31B-it` and every +request resolves there whichever tier it picks. -### Option A — quick start (static token via environment) +Pick one of two setups: + +| | Option A — static token | Option B — auto-refreshing (recommended) | +| --- | --- | --- | +| Setup | Export env vars | One-time refresh token + `settings.json` | +| Token expires | You re-export by hand | Handled for you | +| Good for | A single session, first try | Day-to-day use | -Good for a single session. The token expires, so you'll re-run this when it does. -Export these in your shell before launching `claude`: +### Option A — static token ```bash export ANTHROPIC_BASE_URL='https://ai-poc.hms.edu' +export ANTHROPIC_AUTH_TOKEN="$HMS_AI_TOKEN" # the JWT from step 1 export ANTHROPIC_DEFAULT_OPUS_MODEL='google/gemma-4-31B-it' export ANTHROPIC_DEFAULT_SONNET_MODEL='google/gemma-4-31B-it' export ANTHROPIC_DEFAULT_HAIKU_MODEL='google/gemma-4-31B-it' -export ANTHROPIC_AUTH_TOKEN='[YOUR_TOKEN_HERE]' ``` -Set `ANTHROPIC_AUTH_TOKEN` to the JWT from step 1 (e.g. `$HMS_AI_TOKEN`). It is -sent as `Authorization: Bearer `, the header the gateway expects. Do -**not** use `ANTHROPIC_API_KEY` here — that sends `x-api-key` instead. +Use `ANTHROPIC_AUTH_TOKEN`, **not** `ANTHROPIC_API_KEY`: the former sends +`Authorization: Bearer `, which is what the gateway expects, while the +latter sends `x-api-key`. -### Option B — recommended: auto-refreshing token via `apiKeyHelper` +### Option B — auto-refreshing token via `apiKeyHelper` -Because the JWT is short-lived, the robust setup is an `apiKeyHelper` script that -Claude Code runs to fetch a fresh token on demand (and on any `401`). Whatever the -script prints to stdout is sent as both `Authorization: Bearer ` and +`apiKeyHelper` is a script Claude Code runs to fetch a token on demand and on any +`401`. Its stdout is sent as both `Authorization: Bearer ` and `x-api-key: `; the gateway reads the Bearer header. -This repo ships a ready-made, non-interactive helper: [`hms-ai-token.sh`](./hms-ai-token.sh). -It prints only the access token to stdout and reads credentials from the -environment. It supports two modes: +This repo ships one: [`hms-ai-token.sh`](./hms-ai-token.sh). It prints only the +token to stdout (everything else goes to stderr) and logs in via a stored refresh +token, falling back to `HMS_USERNAME`/`HMS_PASSWORD` if that token stops working +— which makes the setup self-healing. -- **Refresh token (recommended, password-free):** set `HMS_REFRESH_TOKEN` - (get it with `./get-okta-token.sh --refresh` — see - [Keeping the token fresh](#keeping-the-token-fresh)). -- **Password grant (fallback):** set `HMS_USERNAME` and `HMS_PASSWORD`. +**1. Seed the refresh token** into the file the helper manages: ```bash -# Provide credentials via your shell profile or a secrets manager, e.g.: -export HMS_REFRESH_TOKEN="..." # or: export HMS_USERNAME=... HMS_PASSWORD=... +mkdir -p ~/.claude +umask 077; ./get-okta-token.sh --refresh \ + | awk -F'Refresh: ' '/Refresh:/{print $2}' > ~/.claude/.hms_refresh_token -# Sanity-check it prints a token: -./hms-ai-token.sh +# Optional password fallback, from your shell profile or a secrets manager +export HMS_USERNAME=... HMS_PASSWORD=... + +# Sanity check — run it twice, both must print a token +./hms-ai-token.sh && ./hms-ai-token.sh ``` -Point `apiKeyHelper` at it (use an absolute path — copy it somewhere stable such -as `~/.claude/hms-ai-token.sh`, or reference it in place): +Running it twice is the real test: it proves token rotation is being persisted. +See [Refresh tokens](#refresh-tokens) for why. + +**2. Install the helper** somewhere stable, since `apiKeyHelper` needs an +absolute path: ```bash cp hms-ai-token.sh ~/.claude/hms-ai-token.sh && chmod 700 ~/.claude/hms-ai-token.sh ``` -Then configure Claude Code in `~/.claude/settings.json` (user scope) or a +**3. Configure Claude Code** in `~/.claude/settings.json` (all projects) or a project's `.claude/settings.json`: ```json @@ -302,63 +288,82 @@ project's `.claude/settings.json`: } ``` -> With `apiKeyHelper`, leave `ANTHROPIC_AUTH_TOKEN` unset — the helper output is -> used as the bearer token instead. - -- `CLAUDE_CODE_API_KEY_HELPER_TTL_MS` controls how often the helper is re-run - (here, every 5 minutes). Claude Code also re-runs it automatically on a `401`. - Set this comfortably below the token's actual lifetime. -- `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` turns off auto-updates, telemetry, - and model discovery — appropriate when pointing at a private gateway. -- Settings-file `env` values override shell exports of the same variable. +- Leave `ANTHROPIC_AUTH_TOKEN` unset — the helper's output is the bearer token. +- `CLAUDE_CODE_API_KEY_HELPER_TTL_MS` is how often the helper re-runs (5 minutes + here). Keep it comfortably under the token's real lifetime; Claude Code also + re-runs the helper on a `401`. +- `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` disables auto-updates, telemetry, + and model discovery — appropriate for a private gateway. +- Values in a settings `env` block override shell exports of the same variable. -### Verify it works +**4. Verify:** ```bash claude -p "Reply with exactly: ok" ``` -If you get `401`/`403`, your token is missing or expired (re-run the helper / -re-acquire the token). If you get `429`, you've hit your LLM quota. +`401`/`403` means the token is missing or expired; `429` means you've hit your +quota. --- -## Keeping the token fresh +## Refresh tokens -The `openid offline_access` scope returns a `refresh_token` alongside the access -token — a long-lived credential that mints new access tokens without your -password. It's what the Option B helper uses. +The `openid offline_access` scope returns a `refresh_token` next to the access +token. It's longer-lived and mints new access tokens without your password — +this is what `hms-ai-token.sh` uses. -**Get your refresh token.** Run `get-okta-token.sh` with `-r`/`--refresh`; it -prints the refresh token on a second `Refresh:` line: +Get one with: ```bash ./get-okta-token.sh --refresh # Enter Username: your-hms-id # Password: ******** -# Token: eyJraWQiOi... # Refresh: 0.AR8A... - -# Capture just the refresh token into an env var: -export HMS_REFRESH_TOKEN="$(./get-okta-token.sh --refresh | awk -F'Refresh: ' '/Refresh:/{print $2}')" ``` -**Use it** to obtain new access tokens without re-entering your password: +### They rotate — each one is single-use + +Every `grant_type=refresh_token` call returns a **new** `refresh_token`, and Okta +retires the one you just spent after a grace period of roughly 30 seconds. You +must save the replacement and send that one next time. + +Ignoring the returned `refresh_token` is the most common way to break this setup: +the first refresh succeeds, then every later one fails with +`invalid_grant: The refresh token is invalid or expired.` + +This is also why the token **must live in a file, not an environment variable**. +A script can't write to its parent shell's environment, so a token held in +`HMS_REFRESH_TOKEN` gets replayed after it's been retired — the helper works once +or twice, then fails. Accordingly, `hms-ai-token.sh` owns +`~/.claude/.hms_refresh_token` (mode `600`), rewrites it after every login, and +takes a lock so concurrent runs can't invalidate each other's token. Set +`HMS_REFRESH_TOKEN_FILE` to keep it elsewhere. `HMS_REFRESH_TOKEN` still works, +but only to bootstrap the file on a first run. + +Treat that file like a password: until it expires or is revoked, it grants access +without your credentials. + +
+Doing the refresh by hand + +Prefer the helper — it handles rotation and locking — but the flow is: ```bash -curl --silent --request POST \ +response="$(curl --silent --request POST \ --url "https://login.hms.harvard.edu/oauth2/aus155lzzptyDTgN3698/v1/token" \ --header "Accept: application/json" \ --header "Content-Type: application/x-www-form-urlencoded" \ --data "client_id=0oa139tiylzbW6XnX698" \ --data "grant_type=refresh_token" \ - --data "refresh_token=$HMS_REFRESH_TOKEN" \ - --data "scope=openid offline_access" \ -| jq -r '.access_token' + --data-urlencode "refresh_token=$(cat ~/.claude/.hms_refresh_token)" \ + --data "scope=openid offline_access")" + +echo "$response" | jq -r '.access_token' # use now +echo "$response" | jq -r '.refresh_token' > ~/.claude/.hms_refresh_token # use next time ``` -Store the refresh token securely (secrets manager or a `chmod 600` file), and -have your `apiKeyHelper` use this flow so Claude Code never needs your password. +
--- @@ -366,8 +371,11 @@ have your `apiKeyHelper` use this flow so Claude Code never needs your password. | Symptom | Likely cause | Fix | | --- | --- | --- | -| `401` / `403` from the gateway | No token, expired token, or malformed `Authorization` header | Re-acquire the token; confirm the header is `Authorization: Bearer ` | -| `429` | LLM quota exceeded | Back off and retry; request more quota from the platform team | -| Token endpoint returns an error | Wrong username/password, or the account isn't enabled for the service | Verify HMS credentials; contact the platform team | -| Claude Code ignores your token | `ANTHROPIC_API_KEY` set (takes the `x-api-key` path) or a settings `env` block overriding your shell var | Use `ANTHROPIC_AUTH_TOKEN` / `apiKeyHelper`; check `~/.claude/settings.json` | -| Model-not-found error | Wrong model ID | Use `google/gemma-4-31B-it` (or the current ID from the platform team) | +| `401` / `403` from the gateway | No token, expired token, or malformed header | Re-acquire the token; confirm the header is `Authorization: Bearer ` | +| `429` | LLM quota exceeded | Back off and retry; ask the platform team for more quota | +| Token endpoint returns an error | Wrong username/password, or account not enabled for the service | Check credentials (username ≠ email); contact the platform team | +| `invalid_grant: The refresh token is invalid or expired` — works at first, then fails | The rotated refresh token wasn't saved, so a retired one is being replayed. Classically: it's in `HMS_REFRESH_TOKEN` rather than a file | Use `hms-ai-token.sh`, which manages the file itself; re-seed with `get-okta-token.sh --refresh`. See [Refresh tokens](#refresh-tokens) | +| `timed out waiting for another token refresh to finish` | A crashed run left `~/.claude/.hms_refresh_token.lock` behind | Locks older than 2 minutes clear themselves; otherwise `rmdir ~/.claude/.hms_refresh_token.lock` | +| Claude Code ignores your token | `ANTHROPIC_API_KEY` is set (takes the `x-api-key` path), or a settings `env` block overrides your shell var | Use `ANTHROPIC_AUTH_TOKEN` or `apiKeyHelper`; check `~/.claude/settings.json` | +| Model-not-found | Wrong model ID | Use `google/gemma-4-31B-it`; `GET /v1/models` lists what the backend serves | +| Nothing connects at all | Not on the HMS VPN | Connect and retry | diff --git a/get-okta-token.sh b/get-okta-token.sh index 00c621a..0ef9839 100755 --- a/get-okta-token.sh +++ b/get-okta-token.sh @@ -14,10 +14,10 @@ # - jq (used to parse the JSON response) # # Usage: -# ./get-okta-token.sh [--refresh] +# ./get-okta-token.sh [-r|--refresh] # # You will be prompted interactively for your username and password -# (the password input is hidden). Pass --refresh to print the refresh +# (the password input is hidden). Pass -r/--refresh to print the refresh # token instead of the access token. # # Configuration (edit the variables below if the environment changes): @@ -42,9 +42,9 @@ SCOPE="openid offline_access" OKTA_CLIENT_ID="0oa139tiylzbW6XnX698" WANT_REFRESH=false -if [ "$1" = "--refresh" ]; then - WANT_REFRESH=true -fi +case "$1" in + -r|--refresh) WANT_REFRESH=true ;; +esac read -rp "Enter Username: " USER_NAME read -rsp "Password: " PASSWORD diff --git a/hms-ai-token.sh b/hms-ai-token.sh index 49ae672..abef9cd 100755 --- a/hms-ai-token.sh +++ b/hms-ai-token.sh @@ -10,27 +10,50 @@ # prints a fresh token. # # HOW TO GIVE IT YOUR LOGIN -# The script does NOT ask you to type your password each time. Instead, you -# provide your login details once, as "environment variables", and the script -# reads them. Pick ONE of these two options: +# Pick ONE of these two options: # # Option 1 - Refresh token (recommended, no password needed): -# export HMS_REFRESH_TOKEN="paste-your-refresh-token-here" +# mkdir -p ~/.claude +# umask 077; ./get-okta-token.sh --refresh \ +# | awk -F'Refresh: ' '/Refresh:/{print $2}' > ~/.claude/.hms_refresh_token # A refresh token is a long-lived key you get once (see the README, step 1). # It lets the script renew your access without your password. # +# As a convenience you may instead export it once: +# export HMS_REFRESH_TOKEN="paste-your-refresh-token-here" +# The script uses that only to bootstrap — on the first successful login it +# writes the token to the file above and uses the file from then on. See +# "WHY THE FILE MATTERS" below; do NOT rely on the variable alone. +# # Option 2 - Username and password: # export HMS_USERNAME="your-hms-id" # export HMS_PASSWORD="your-password" +# Also used automatically as a fallback if the refresh token stops working. # # Tip: put whichever "export" lines you choose in your shell profile # (e.g. ~/.bashrc or ~/.zshrc), or better, store them in a password manager. # Never type your real password directly into this file. # +# WHY THE FILE MATTERS (read this if you ever saw "refresh token is invalid") +# Okta rotates refresh tokens: every time this script spends one, Okta issues +# a replacement and retires the old one seconds later. A refresh token is +# therefore single-use, so it cannot live in an environment variable — a +# script cannot change its parent shell's variables, so the next run would +# replay a token Okta has already retired and the login would fail. Instead +# this script keeps the current token in a file it owns and rewrites that file +# after every login. A lock serialises concurrent runs (Claude Code may +# invoke this script several times at once) so two logins never race and +# invalidate each other's token. +# +# WHERE THE TOKEN IS STORED +# ~/.claude/.hms_refresh_token (mode 600, created automatically) +# Override with HMS_REFRESH_TOKEN_FILE=/path/to/file if you prefer elsewhere. +# # HOW TO RUN IT # ./hms-ai-token.sh -# It prints just the token, nothing else, so it works both when you run it -# yourself and when Claude Code calls it automatically. +# It prints just the token to stdout, nothing else, so it works both when you +# run it yourself and when Claude Code calls it automatically. Everything +# else — warnings, errors — goes to stderr. # ============================================================================= # Stop immediately if anything goes wrong, so we never print a broken token. @@ -43,49 +66,162 @@ CLIENT_ID="0oa139tiylzbW6XnX698" SCOPE="openid offline_access" TOKEN_ENDPOINT="$OKTA_URL/oauth2/$AUTH_SERVER/v1/token" +# --- Where we keep the current refresh token --------------------------------- +REFRESH_FILE="${HMS_REFRESH_TOKEN_FILE:-$HOME/.claude/.hms_refresh_token}" +LOCK_DIR="$REFRESH_FILE.lock" +LOCK_WAIT_SECONDS=30 # how long to wait for another run to finish +LOCK_STALE_MINUTES=2 # after this, assume a crashed run left the lock behind + # Print an error message and exit. Errors go to the screen (stderr), never mixed # in with the token, so a tool reading the token never sees them by mistake. die() { echo "hms-ai-token: $*" >&2; exit 1; } +warn() { echo "hms-ai-token: $*" >&2; } # --- Make sure the tools we rely on are installed ---------------------------- command -v curl >/dev/null || die "the 'curl' program is required but not installed" command -v jq >/dev/null || die "the 'jq' program is required but not installed" +# --- Lock, so two simultaneous runs don't spend the same refresh token ------- +# 'mkdir' either creates the directory or fails, atomically, on every platform — +# which is exactly the "only one winner" behaviour a lock needs. +have_lock=false +release_lock() { + if [[ "$have_lock" == true ]]; then + rmdir "$LOCK_DIR" 2>/dev/null || true + have_lock=false + fi +} + +acquire_lock() { + mkdir -p -- "$(dirname -- "$REFRESH_FILE")" \ + || die "could not create $(dirname -- "$REFRESH_FILE") to store the refresh token" + local waited=0 + while ! mkdir "$LOCK_DIR" 2>/dev/null; do + # A lock older than LOCK_STALE_MINUTES belongs to a run that died; clear it. + if find "$LOCK_DIR" -maxdepth 0 -mmin +"$LOCK_STALE_MINUTES" 2>/dev/null | grep -q .; then + warn "clearing a stale lock left behind by an earlier run" + rmdir "$LOCK_DIR" 2>/dev/null || true + fi + sleep 1 + waited=$((waited + 1)) + if (( waited >= LOCK_WAIT_SECONDS )); then + die "timed out waiting for another token refresh to finish. If nothing else is running, remove $LOCK_DIR" + fi + done + have_lock=true + trap release_lock EXIT INT TERM +} + +# --- Read / write the stored refresh token ----------------------------------- +read_stored_refresh() { + [[ -s "$REFRESH_FILE" ]] || return 0 + # Strip any whitespace, so a stray newline from 'echo >' can't corrupt it. + tr -d '[:space:]' < "$REFRESH_FILE" +} + +save_refresh() { + local value="$1" tmp + tmp="$(mktemp -- "$REFRESH_FILE.XXXXXX")" \ + || die "could not write to $(dirname -- "$REFRESH_FILE")" + chmod 600 -- "$tmp" + printf '%s\n' "$value" > "$tmp" + # Rename, rather than overwrite in place, so the file is never half-written. + mv -f -- "$tmp" "$REFRESH_FILE" +} + +# --- Ask Okta for a token ---------------------------------------------------- +# Extra login arguments are passed in; --data-urlencode escapes the values, so +# passwords and tokens containing '&', '+' or '%' are sent correctly. +okta_post() { + curl --silent --show-error --request POST \ + --url "$TOKEN_ENDPOINT" \ + --header "Accept: application/json" \ + --data-urlencode "client_id=$CLIENT_ID" \ + --data-urlencode "scope=$SCOPE" \ + "$@" +} + +# Okta's explanation for a rejected login, if it gave one. +okta_error() { + jq -r '[.error, .error_description] | map(select(. != null)) | join(": ")' <<<"$1" 2>/dev/null +} + +acquire_lock + # --- Decide how to log in, based on what you provided ------------------------ -# We assemble the extra pieces of the login request into the 'login' array. -declare -a login -if [[ -n "${HMS_REFRESH_TOKEN:-}" ]]; then - # Option 1: renew using the refresh token (no password needed). - login=(--data "grant_type=refresh_token" - --data "refresh_token=$HMS_REFRESH_TOKEN") -elif [[ -n "${HMS_USERNAME:-}" && -n "${HMS_PASSWORD:-}" ]]; then - # Option 2: log in with username and password. - login=(--data "grant_type=password" - --data "username=$HMS_USERNAME" - --data "password=$HMS_PASSWORD") -else - die "no login details found. Set HMS_REFRESH_TOKEN, or set both HMS_USERNAME and HMS_PASSWORD (see the notes at the top of this script)" +# The file wins over the variable: it holds the token Okta most recently issued, +# whereas the variable still holds whatever was exported at login time. +refresh_token="$(read_stored_refresh)" +refresh_source="$REFRESH_FILE" +if [[ -z "$refresh_token" && -n "${HMS_REFRESH_TOKEN:-}" ]]; then + refresh_token="$HMS_REFRESH_TOKEN" + refresh_source="\$HMS_REFRESH_TOKEN" fi -# --- Ask Okta for a token ---------------------------------------------------- -response="$(curl --silent --show-error --request POST \ - --url "$TOKEN_ENDPOINT" \ - --header "Accept: application/json" \ - --header "Content-Type: application/x-www-form-urlencoded" \ - --data "client_id=$CLIENT_ID" \ - --data "scope=$SCOPE" \ - "${login[@]}")" || die "could not reach the login server. Check your internet connection or VPN" - -# --- Pull the token out of Okta's reply -------------------------------------- -# Okta replies with a bundle of JSON; 'jq' picks out just the access token. -token="$(echo "$response" | jq -r '.access_token // empty')" - -if [[ -z "$token" ]]; then - # No token means the login was rejected. Show Okta's explanation if there is - # one, without dumping the whole reply (which can contain sensitive details). - reason="$(echo "$response" | jq -r '[.error, .error_description] | map(select(. != null)) | join(": ")' 2>/dev/null)" - die "login failed${reason:+ ($reason)}. Double-check your credentials" +have_password_login=false +if [[ -n "${HMS_USERNAME:-}" && -n "${HMS_PASSWORD:-}" ]]; then + have_password_login=true +fi + +if [[ -z "$refresh_token" && "$have_password_login" == false ]]; then + die "no login details found. Store a refresh token in $REFRESH_FILE, or set both HMS_USERNAME and HMS_PASSWORD (see the notes at the top of this script)" +fi + +access_token="" + +# --- Attempt 1: renew using the refresh token (no password needed) ----------- +if [[ -n "$refresh_token" ]]; then + response="$(okta_post \ + --data-urlencode "grant_type=refresh_token" \ + --data-urlencode "refresh_token=$refresh_token")" \ + || die "could not reach the login server. Check your internet connection or VPN" + + access_token="$(jq -r '.access_token // empty' <<<"$response")" + + if [[ -n "$access_token" ]]; then + # THE IMPORTANT PART: keep the replacement token Okta just issued. Without + # this, the token we spent above is retired and the next run would fail. + rotated="$(jq -r '.refresh_token // empty' <<<"$response")" + if [[ -n "$rotated" ]]; then + save_refresh "$rotated" + elif [[ ! -s "$REFRESH_FILE" ]]; then + # This Okta app doesn't rotate; still move the token into the file so the + # file is the single source of truth from now on. + save_refresh "$refresh_token" + fi + else + reason="$(okta_error "$response")" + if [[ "$have_password_login" == true ]]; then + warn "the stored refresh token was rejected${reason:+ ($reason)}; falling back to HMS_USERNAME/HMS_PASSWORD" + else + die "the refresh token in $refresh_source was rejected${reason:+ ($reason)}. Get a new one with 'get-okta-token.sh --refresh' and store it in $REFRESH_FILE, or set HMS_USERNAME and HMS_PASSWORD as a fallback" + fi + fi +fi + +# --- Attempt 2: log in with username and password ---------------------------- +if [[ -z "$access_token" ]]; then + response="$(okta_post \ + --data-urlencode "grant_type=password" \ + --data-urlencode "username=$HMS_USERNAME" \ + --data-urlencode "password=$HMS_PASSWORD")" \ + || die "could not reach the login server. Check your internet connection or VPN" + + access_token="$(jq -r '.access_token // empty' <<<"$response")" + if [[ -z "$access_token" ]]; then + # No token means the login was rejected. Show Okta's explanation if there is + # one, without dumping the whole reply (which can contain sensitive details). + reason="$(okta_error "$response")" + die "login failed${reason:+ ($reason)}. Double-check your credentials" + fi + + # A password login also yields a fresh refresh token — store it, so subsequent + # runs are password-free again. + rotated="$(jq -r '.refresh_token // empty' <<<"$response")" + if [[ -n "$rotated" ]]; then + save_refresh "$rotated" + fi fi # Success: print ONLY the token. -printf '%s\n' "$token" +printf '%s\n' "$access_token"