Skip to content
Merged
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
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ src/
├── cli.rs clap argument structs + Stage + slug validation
├── config.rs Config / Profile / AppContext + on-disk persistence
├── api.rs stage → API URL resolution
├── auth_cache.rs AuthCache (Bearer | Basic) + 0600 on-disk store
├── auth_cache.rs AuthCache (ClientCredentials | Bearer | Basic) + 0600 on-disk store
├── http.rs ApiClient: auth-attached reqwest wrapper
├── jsonapi.rs generic Document / Resource / Single / List envelopes
├── prompt.rs interactive yes_no / text / stage / organization prompts
Expand Down
26 changes: 23 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ rw config profile rm mercy # Remove the "mercy" profile (prompts fo
rw config profile rm mercy --yes # Remove without prompting
rw config profile set mercy -o new-org # Update organization for a profile
rw config profile set mercy -g sandbox # Update stage for a profile
rw config profile auth mercy # Save basic auth credentials for a profile (see below)
rw config profile auth mercy # Save credentials for a profile (see below)
```

#### Overriding the stage
Expand Down Expand Up @@ -131,6 +131,26 @@ rw config profile auth mercy --username alice \
| `--username` | `-u` | Username for basic auth |
| `--password` | `-P` | Password for basic auth |

#### Using Client Credentials

For non-interactive use (CI, scripts), store a client ID and secret instead of logging in:

```sh
rw config profile auth mercy --client-id client_123 # Secret prompted securely
rw config profile auth mercy --client-id client_123 \
--client-secret secret # Fully non-interactive
```

| Flag | Description |
|-------------------|---------------------------------------------------------------|
| `--client-id` | Client ID (cannot be combined with `--username`/`--password`) |
| `--client-secret` | Client secret (prompted securely if not provided) |

`rw` exchanges the credentials for an access token on first use and caches it in
`~/.config/rw/auth/{profile}.json`, renewing it automatically. `rw auth login` on such a
profile forces a fresh exchange instead of opening a browser; `rw auth logout` only drops the
cached access token and keeps the client credentials (use `rw config profile rm` to remove them).

#### Diagnostics

`rw config doctor` runs a fixed sequence of checks against the active profile (or `--profile`):
Expand All @@ -154,10 +174,10 @@ Each check reports `pass`, `warn`, `fail`, `skip`, or `info`. A later check is s
### Authentication

```sh
rw auth login # Open browser and authenticate via WorkOS
rw auth login # Open browser and authenticate via WorkOS (profiles with client credentials exchange them instead)
rw auth status # Show authentication status for current profile
rw auth header # Show the authentication header for current profile
rw auth logout # Remove stored credentials for current profile
rw auth logout # Remove stored credentials for current profile (client credentials: cached token only)

# Use a named profile
rw auth login --profile mercy
Expand Down
16 changes: 16 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,19 @@ Basic credentials (written using `rw config profile auth <name>`):
"password": "<plaintext-password>"
}
```

Client credentials (written using `rw config profile auth <name> --client-id … --client-secret …`):

```json
{
"client_id": "<client-id>",
"client_secret": "<client-secret>",
"access_token": "<jwt>",
"expires_at": 1234567890
}
```

`access_token` and `expires_at` are absent until the first operation that needs a token (an
API call, `rw auth login` or `rw config doctor`). `rw` then exchanges the credentials for an
access token (OAuth `client_credentials` grant) and writes it back here, re-exchanging when
it is within 60 seconds of expiry. `rw auth logout` removes only the cached token.
9 changes: 9 additions & 0 deletions skills/rw-skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@ rw config profile list # List all configured profiles
rw config profile show # Show the active profile
```

**Non-interactive credentials (client credentials grant):**

```sh
rw config profile auth <profile> --client-id <id> # Secret prompted securely
rw config profile auth <profile> --client-id <id> --client-secret <s> # Fully non-interactive
```

`--client-id` / `--client-secret` cannot be combined with `--username` / `--password`, and with `--json` both are required. `rw` exchanges them for an access token on the first command that needs one and caches it, renewing automatically. `rw auth login` on such a profile forces a fresh exchange instead of opening a browser; `rw auth logout` drops only the cached token and keeps the credentials. A `--client-secret` passed on the command line is visible in shell history and process listings, so prefer the prompt unless the value comes from a secret store.

### `rw clinicians` — Clinician Management

All targets accept a UUID or email address. Roles accept a UUID or name. Teams accept a UUID or abbreviation.
Expand Down
86 changes: 84 additions & 2 deletions src/auth_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ use std::path::{Path, PathBuf};
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(untagged)]
pub enum AuthCache {
/// Declared before `Bearer`: a cached token gives it the same `access_token` +
/// `expires_at` fields, and untagged enums take the first variant that matches.
ClientCredentials {
client_id: String,
client_secret: String,
/// Access token from the last exchange, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
access_token: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
expires_at: Option<i64>,
},
Bearer {
access_token: String,
#[serde(skip_serializing_if = "Option::is_none")]
Expand All @@ -20,10 +31,17 @@ pub enum AuthCache {
}

impl AuthCache {
/// Returns true if this is a bearer token that is expired or expires within 60 seconds.
/// Returns true if this is a bearer or client-credentials token that is missing,
/// expired, or expires within 60 seconds.
pub fn is_expired(&self) -> bool {
match self {
AuthCache::Bearer { expires_at, .. } => unix_now() >= expires_at - 60,
AuthCache::ClientCredentials {
access_token: Some(_),
expires_at: Some(expires_at),
..
}
| AuthCache::Bearer { expires_at, .. } => unix_now() >= expires_at - 60,
AuthCache::ClientCredentials { .. } => true,
AuthCache::Basic { .. } => false,
}
}
Expand Down Expand Up @@ -219,6 +237,70 @@ mod tests {
}
}

fn client_credentials(token: Option<&str>, expires_at: Option<i64>) -> AuthCache {
AuthCache::ClientCredentials {
client_id: "id".to_string(),
client_secret: "sec".to_string(),
access_token: token.map(str::to_string),
expires_at,
}
}

#[test]
fn test_client_credentials_without_token_is_expired() {
assert!(client_credentials(None, None).is_expired());
}

#[test]
fn test_client_credentials_fresh_token_not_expired() {
assert!(!client_credentials(Some("t"), Some(unix_now() + 3600)).is_expired());
}

#[test]
fn test_client_credentials_token_in_grace_period_is_expired() {
assert!(client_credentials(Some("t"), Some(unix_now() + 30)).is_expired());
}

#[test]
fn test_client_credentials_with_cached_token_roundtrips_as_client_credentials() {
// Must not be swallowed by `Bearer`, which also has access_token + expires_at.
let json = serde_json::to_string(&client_credentials(Some("t"), Some(9999999999))).unwrap();
match serde_json::from_str::<AuthCache>(&json).unwrap() {
AuthCache::ClientCredentials {
client_id,
client_secret,
access_token,
expires_at,
} => {
assert_eq!(client_id, "id");
assert_eq!(client_secret, "sec");
assert_eq!(access_token.as_deref(), Some("t"));
assert_eq!(expires_at, Some(9999999999));
}
other => panic!("expected client credentials, got {:?}", other),
}
}

#[test]
fn test_client_credentials_without_token_omits_token_fields() {
let json = serde_json::to_string(&client_credentials(None, None)).unwrap();
assert!(!json.contains("access_token"));
assert!(!json.contains("expires_at"));
assert!(matches!(
serde_json::from_str::<AuthCache>(&json).unwrap(),
AuthCache::ClientCredentials { .. }
));
}

#[test]
fn test_bearer_json_still_parses_as_bearer() {
let json = r#"{"access_token":"a","refresh_token":"r","expires_at":9999999999}"#;
assert!(matches!(
serde_json::from_str::<AuthCache>(json).unwrap(),
AuthCache::Bearer { .. }
));
}

#[test]
fn test_expires_at_from_duration() {
let before = unix_now();
Expand Down
54 changes: 53 additions & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ pub enum ConfigProfileCommands {
Rm(ConfigProfileRmArgs),
/// Add a new profile.
Add(ConfigProfileAddArgs),
/// Save basic auth credentials for a profile.
/// Save credentials (basic auth or client credentials) for a profile.
Auth(ConfigProfileAuthArgs),
}

Expand Down Expand Up @@ -339,6 +339,12 @@ pub struct ConfigProfileAuthArgs {
/// Password (prompted securely if not provided).
#[arg(short = 'P', long)]
pub password: Option<String>,
/// Client ID for the client credentials grant (selects client credentials over basic auth).
#[arg(long, conflicts_with_all = ["username", "password"])]
pub client_id: Option<String>,
/// Client secret for the client credentials grant (prompted securely if not provided).
#[arg(long, conflicts_with_all = ["username", "password"])]
pub client_secret: Option<String>,
Comment thread
shadowhand marked this conversation as resolved.
}

/// Arguments for `config updates`.
Expand Down Expand Up @@ -513,6 +519,52 @@ pub struct SkillsInstallArgs {
mod tests {
use super::*;

#[test]
fn test_profile_auth_client_flags_conflict_with_basic_flags() {
use clap::Parser;
assert!(Cli::try_parse_from([
"rw",
"config",
"profile",
"auth",
"demo",
"--client-id",
"a",
"--username",
"b",
])
.is_err());
assert!(Cli::try_parse_from([
"rw",
"config",
"profile",
"auth",
"demo",
"--client-secret",
"a",
"--password",
"b",
])
.is_err());
}

#[test]
fn test_profile_auth_client_flags_parse() {
use clap::Parser;
assert!(Cli::try_parse_from([
"rw",
"config",
"profile",
"auth",
"demo",
"--client-id",
"a",
"--client-secret",
"b",
])
.is_ok());
}

#[test]
fn test_auth_flag_long() {
use clap::Parser;
Expand Down
Loading
Loading