Skip to content
41 changes: 40 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,42 @@ olk auth login --browser [--client-id YOUR_CLIENT_ID --tenant-id YOUR_TENANT_ID]

This flow requires the app registration to have a loopback redirect URI (see [Custom App Registration](#custom-app-registration) step 4). If the browser shows error **AADSTS50011** ("redirect URI mismatch") and the CLI times out, the app registration is missing the redirect URI — use `--client-id` with your own registration.

### Access-Token Injection (CI, containers, agent sandboxes)

When an external system already owns the OAuth flow, hand `olk` a short-lived **delegated Graph access token** instead of logging in. In this mode `olk` is a pure consumer of that token:

- it **never** reads or writes the OS keyring, the account files, or the default-account config;
- it **never** refreshes and **never** persists the token — the token's lifetime is the process's lifetime;
- no refresh token ever enters the environment, so a disposable container holds nothing durable.

| Variable | Required | Description |
|----------|----------|-------------|
| `OLK_ACCESS_TOKEN` | yes | A delegated Microsoft Graph access token (bearer). Also available as `--access-token`, but prefer the variable: a command line is visible to other processes. |
| `OLK_ACCESS_TOKEN_EXPIRES_AT` | no | RFC3339 expiry of that token (e.g. `2030-01-01T12:34:56Z`). A past value fails **before** any network call with exit code **77**, so a caller can mint a fresh token and retry. Omit it and Graph's `401` is the authority instead. |
| `OLK_ACCOUNT_EMAIL` | no | Identity hint (UPN) shown by `olk auth status`. `olk whoami` still resolves the real profile from Graph. |

```bash
# In a CI job or sandbox: read-only, no prompts, no stored credentials.
export OLK_ACCESS_TOKEN="$(fetch-token-from-your-broker)"
export OLK_ACCESS_TOKEN_EXPIRES_AT="2030-01-01T12:34:56Z"
export OLK_NO_WRITE=1 OLK_NO_INPUT=1

olk mail list --json
olk auth status
# Account: user@example.com
# Status: Authenticated (injected access token)
# Expires: 2030-01-01T12:34:56Z
```

The token must carry the Graph scopes for the commands you run (the same scopes `olk auth login` requests). Every capability guard composes normally — `--no-write`, `--no-send`, `--no-input`, `--wrap-untrusted`, `--enable-commands[-exact]`, `--disable-commands` — and `--mailbox` delegated access keeps working.

Notes:

- `--account` selects among *stored* accounts, so it is rejected in this mode. Use `--mailbox` for delegated access, or `OLK_ACCOUNT_EMAIL` as a display hint.
- `olk auth login|logout|clean|list` manage stored accounts and refuse to run while `OLK_ACCESS_TOKEN` is set. `olk auth status` reports the injected token.
- Under `olk mcp`, every tool call rebuilds the credential from the environment, so the same variables apply with no extra configuration. Because the token is not refreshed, keep the server's lifetime shorter than the token's.
- The token never appears in output, errors, verbose HTTP logs (the `Authorization` header is redacted), or the argv the MCP server builds.

### Custom App Registration

If your organization blocks the default client ID, or your admin requires apps to be registered under your tenant, you'll need to create your own app registration:
Expand Down Expand Up @@ -338,7 +374,7 @@ For common workflows, `olk` provides top-level shortcuts:
|------|---------|-------------|
| `--json` | `OLK_JSON` | JSON output |
| `--plain` | `OLK_PLAIN` | TSV output |
| `--account EMAIL` | `OLK_ACCOUNT` | Account to use |
| `--account EMAIL` | `OLK_ACCOUNT` | Account to use (stored accounts only) |
| `--mailbox EMAIL` | `OLK_MAILBOX` | Target another user's mailbox via delegated access (requires `Mail.Read.Shared` at login) |
| `-v, --verbose` | `OLK_VERBOSE` | Verbose output |
| `--dry-run` | `OLK_DRY_RUN` | Dry run mode |
Expand All @@ -355,6 +391,9 @@ For common workflows, `olk` provides top-level shortcuts:
| `--enable-commands CSV` | `OLK_ENABLE_COMMANDS` | Allow only these command prefixes (e.g. `mail,calendar`) |
| `--enable-commands-exact CSV` | `OLK_ENABLE_COMMANDS_EXACT` | Allow only these exact command paths (e.g. `mail.list,mail.get`) |
| `--disable-commands CSV` | `OLK_DISABLE_COMMANDS` | Block these command paths (overrides allows) |
| `--access-token TOKEN` | `OLK_ACCESS_TOKEN` | Use an injected Graph access token; bypasses the keyring entirely ([details](#access-token-injection-ci-containers-agent-sandboxes)) |
| `--access-token-expires-at TS` | `OLK_ACCESS_TOKEN_EXPIRES_AT` | RFC3339 expiry of the injected token; a past value exits 77 before any request |
| `--account-email EMAIL` | `OLK_ACCOUNT_EMAIL` | Identity hint (UPN) displayed when a token is injected |
| | `OLK_KEYRING_PASSWORD` | File-backend keyring password (for headless use) |

These capability guards apply to **every** entry path — the bare CLI, scripts/CI, and the MCP server — so `OLK_NO_WRITE=1 olk --mailbox boss@example.com mail list` is a hard "read this mailbox, never write" guarantee.
Expand Down
3 changes: 3 additions & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,9 @@ export OLK_MAILBOX=boss@example.com
| `--color auto\|never\|always` | `OLK_COLOR` | Color mode |
| `--timeout SECONDS` | `OLK_TIMEOUT` | Request timeout (default 60) |
| `--tz TIMEZONE` | `OLK_TIMEZONE` | IANA time zone for display (e.g. `America/New_York`) |
| `--access-token TOKEN` | `OLK_ACCESS_TOKEN` | Use an injected Graph access token instead of a stored login: no keyring, no refresh, no persistence. `--account` is rejected in this mode; `--mailbox` still works |
| `--access-token-expires-at TS` | `OLK_ACCESS_TOKEN_EXPIRES_AT` | RFC3339 expiry of the injected token. A past value exits **77** before any request — mint a fresh token and retry |
| `--account-email EMAIL` | `OLK_ACCOUNT_EMAIL` | Identity hint (UPN) shown by `olk auth status` when a token is injected |

## Capability Guards (CLI, MCP, and scripts)

Expand Down
122 changes: 0 additions & 122 deletions docs/OLK_FORK.md

This file was deleted.

27 changes: 27 additions & 0 deletions internal/cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ type AuthLoginCmd struct {
}

func (c *AuthLoginCmd) Run(ctx *RunContext) error {
if err := refuseInTokenMode(ctx.Flags, "login"); err != nil {
return err
}

clientID := c.ClientID
if clientID == "" {
clientID = config.DefaultClientID
Expand Down Expand Up @@ -106,6 +110,10 @@ type AuthLogoutCmd struct {
}

func (c *AuthLogoutCmd) Run(ctx *RunContext) error {
if err := refuseInTokenMode(ctx.Flags, "logout"); err != nil {
return err
}

cfg, err := ctx.Config()
if err != nil {
return err
Expand Down Expand Up @@ -144,6 +152,10 @@ func (c *AuthLogoutCmd) Run(ctx *RunContext) error {
type AuthCleanCmd struct{}

func (c *AuthCleanCmd) Run(ctx *RunContext) error {
if err := refuseInTokenMode(ctx.Flags, "clean"); err != nil {
return err
}

if !ctx.Flags.Force {
return fmt.Errorf("this will remove ALL stored accounts and tokens; use --force to confirm")
}
Expand Down Expand Up @@ -211,6 +223,10 @@ func (c *AuthCleanCmd) Run(ctx *RunContext) error {
type AuthListCmd struct{}

func (c *AuthListCmd) Run(ctx *RunContext) error {
if err := refuseInTokenMode(ctx.Flags, "list"); err != nil {
return err
}

cfg, err := ctx.Config()
if err != nil {
return err
Expand Down Expand Up @@ -255,6 +271,17 @@ func (c *AuthListCmd) Run(ctx *RunContext) error {
type AuthStatusCmd struct{}

func (c *AuthStatusCmd) Run(ctx *RunContext) error {
// Token mode has no stored account to inspect: report the injected credential
// instead, without touching the keyring or the config.
if tm, err := newTokenMode(ctx.Flags); err != nil {
return err
} else if tm != nil {
fmt.Printf("Account: %s\n", outfmt.Sanitize(tm.accountLabel()))
fmt.Println("Status: Authenticated (injected access token)")
fmt.Printf("Expires: %s\n", tm.expiryLabel())
return nil
}

cfg, err := ctx.Config()
if err != nil {
return err
Expand Down
30 changes: 29 additions & 1 deletion internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"time"
_ "time/tzdata"

"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/alecthomas/kong"

"github.com/rlrghb/olkcli/internal/config"
Expand Down Expand Up @@ -46,6 +47,14 @@ type RootFlags struct {
NoInput bool `help:"Fail instead of prompting (headless/agent safety)" env:"OLK_NO_INPUT"`
WrapUntrusted bool `help:"Wrap external free-text in untrusted-content markers (JSON/plain output)" env:"OLK_WRAP_UNTRUSTED"`

// Injected access token. An external system owns the OAuth flow and supplies a
// short-lived delegated token; olk then never touches the keyring, never
// refreshes, and never persists the token. Prefer the environment variables:
// a command line is visible to other processes on the host.
AccessToken string `help:"Delegated Graph access token; bypasses the keyring entirely (prefer OLK_ACCESS_TOKEN)" env:"OLK_ACCESS_TOKEN" name:"access-token"`
AccessTokenExpiresAt string `help:"RFC3339 expiry of the injected access token; a past value fails before any request" env:"OLK_ACCESS_TOKEN_EXPIRES_AT" name:"access-token-expires-at"`
AccountEmail string `help:"Account identity hint (UPN) used for display when an access token is injected" env:"OLK_ACCOUNT_EMAIL" name:"account-email"`

// Command-scoping allow/deny lists (comma-separated dotted paths).
EnableCommands string `help:"Allow only these command prefixes (csv; e.g. mail,calendar)" env:"OLK_ENABLE_COMMANDS"`
EnableCommandsExact string `help:"Allow only these exact command paths (csv; e.g. mail.list,mail.get)" env:"OLK_ENABLE_COMMANDS_EXACT"`
Expand Down Expand Up @@ -107,6 +116,17 @@ func (r *RunContext) GraphClient() (*graphapi.Client, error) {
return r.client, nil
}

// Token mode short-circuits account resolution: an external system supplied
// the credential, so the keyring, the account files, and the default-account
// config are never touched. This branch must stay above r.Store().
tm, err := newTokenMode(r.Flags)
if err != nil {
return nil, err
}
if tm != nil {
return r.newGraphClient(tm.credential())
}

store, err := r.Store()
if err != nil {
return nil, err
Expand Down Expand Up @@ -135,7 +155,15 @@ func (r *RunContext) GraphClient() (*graphapi.Client, error) {
return nil, fmt.Errorf("getting credentials: %w", err)
}

return r.newGraphClient(cred)
}

// newGraphClient builds, guards, and caches the Graph client for a credential.
// Every credential source funnels through here, so a new authentication path can
// never accidentally bypass the capability guards.
func (r *RunContext) newGraphClient(cred azcore.TokenCredential) (*graphapi.Client, error) {
var client *graphapi.Client
var err error
if r.Flags.Verbose {
client, err = graphapi.NewClientVerbose(cred)
} else {
Expand Down Expand Up @@ -243,7 +271,7 @@ func Execute() int {
err := ctx.Run(runCtx)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %s\n", outfmt.SanitizeMultiline(err.Error()))
return 1
return exitCodeFor(err)
}
return 0
}
Loading
Loading