OAuth2 Token Refresh for aerc

Problem

Email accounts failing in aerc with:

Failed to refresh token: exit status 1

Or for Outlook specifically:

[Outlook].source: failed to read password: Failed to refresh token

Root cause: Refresh tokens expired or revoked.

Common triggers:

  • Outlook: Microsoft tokens expire after 90 days of inactivity

  • Gmail: Google Cloud app in "Testing" mode (tokens expire after 7 days)

Permanent Fix (Gmail Only)

Publish your Google Cloud app to production mode:

  1. Open: console.cloud.google.com/apis/credentials/consent

  2. Change status from Testing β†’ In production

  3. Tokens will no longer expire after 7 days

Alternative: Gmail App Passwords (No OAuth)

Why consider this? Most Unix greybeards (Linus, etc.) avoid OAuth entirely. They either self-host mail or use providers that support app passwords. OAuth token refresh is a Microsoft/Google invention that CLI users tolerate, not embrace.

Outlook.com (personal): No app password option since 2024 - OAuth is mandatory.

Gmail: Still supports app passwords if you have 2FA enabled.

Prerequisites

Generate App Password

  1. Go to: myaccount.google.com/apppasswords

  2. Select app: Mail

  3. Select device: Other (Custom name) β†’ enter "aerc linux"

  4. Click Generate

  5. Copy the 16-character password (spaces don’t matter)

Store App Password

Encrypt and store the app password:

# Create password file (replace YOUR_APP_PASSWORD)
echo "YOUR_APP_PASSWORD" | age -e -R ~/.secrets/.metadata/keys/master.age.pub \
  -o ~/.secrets/email/gmail-primary-apppass.age

chmod 600 ~/.secrets/email/gmail-primary-apppass.age

Update accounts.conf

Change from OAuth2 to plain password auth:

[Gmail-Primary]
# OLD (OAuth2):
# source = imaps+xoauth2://evanrosado100%40gmail.com@imap.gmail.com:993
# source-cred-cmd = ~/atelier/_projects/personal/email-config/scripts/oauth2-token.sh gmail-primary

# NEW (App Password):
source = imaps://evanrosado100%40gmail.com@imap.gmail.com:993
source-cred-cmd = age -d -i ~/.secrets/.metadata/keys/master.age.key ~/.secrets/email/gmail-primary-apppass.age

outgoing = smtps://evanrosado100%40gmail.com@smtp.gmail.com:465
outgoing-cred-cmd = age -d -i ~/.secrets/.metadata/keys/master.age.key ~/.secrets/email/gmail-primary-apppass.age

Note the protocol changes:

  • imaps+xoauth2:// β†’ imaps://

  • smtp+xoauth2:// port 587 β†’ smtps:// port 465

App passwords use standard TLS, not OAuth2 bearer tokens.

Verify

# Test decryption
age -d -i ~/.secrets/.metadata/keys/master.age.key ~/.secrets/email/gmail-primary-apppass.age

# Restart aerc
pkill aerc && aerc

Trade-offs

OAuth2 App Passwords

Token expires, needs refresh

Password never expires (until revoked)

More "secure" (Google’s view)

Simpler, Unix-friendly

Requires custom scripts

Native aerc support

Complex debugging

Easy to troubleshoot

Microsoft forces this

Google still allows choice

Check Token Status

Before re-authenticating, check which tokens are expired:

AGE_KEY=~/.secrets/.metadata/keys/master.age.key

# Check all accounts at once
for acct in outlook gmail-primary gmail-secondary; do
  echo "=== $acct ==="
  age -d -i $AGE_KEY ~/.secrets/email/${acct}-tokens.json.age 2>/dev/null | \
    jq -r '"Expires: \(.expires_at | todate)"' || echo "No token file"
done

Check a single account with more detail:

age -d -i $AGE_KEY ~/.secrets/email/outlook-tokens.json.age | \
  jq '{account: "outlook", expires_at: .expires_at, expires_human: (.expires_at | todate), client_id: .client_id}'

Outlook Re-authentication

1. Get Client ID

Outlook uses a public client (no secret required for personal accounts):

AGE_KEY=~/.secrets/.metadata/keys/master.age.key

age -d -i $AGE_KEY ~/.secrets/email/outlook-tokens.json.age | jq -r '.client_id'

2. Run Setup Script

~/atelier/_projects/personal/email-config/scripts/setup-oauth2.sh outlook

3. Complete Authorization

  1. Paste Client ID when prompted

  2. Leave Client Secret blank (press Enter) - Outlook public client doesn’t need it

  3. Open the provided URL in browser

  4. Sign in with evan.rosado@outlook.com

  5. Copy the code from redirect URL (value after code= until & or end of URL)

    • Microsoft codes start with M.C…​

  6. Paste code when prompted

Gmail Re-authentication

1. Get Credentials

Gmail requires both Client ID and Client Secret:

AGE_KEY=~/.secrets/.metadata/keys/master.age.key

age -d -i $AGE_KEY ~/.secrets/email/gmail-primary-tokens.json.age | \
  jq -r '"Client ID: \(.client_id)\nClient Secret: \(.client_secret)"'

Same credentials work for all Gmail accounts.

2. Run Setup Script

# Primary account (evanrosado100@gmail.com)
~/atelier/_projects/personal/email-config/scripts/setup-oauth2.sh gmail-primary

# Secondary account (erjunior1983@gmail.com)
~/atelier/_projects/personal/email-config/scripts/setup-oauth2.sh gmail-secondary

3. Complete Authorization

  1. Paste Client ID when prompted

  2. Paste Client Secret when prompted

  3. Open the provided URL in browser

  4. CRITICAL: Authorize with the correct Gmail account

    • gmail-primary β†’ evanrosado100@gmail.com

    • gmail-secondary β†’ erjunior1983@gmail.com

  5. Copy the code from redirect URL (value after code= until &scope=)

    • Google codes start with 4/0A…​

  6. Paste code when prompted

If you authorize with the wrong Gmail account, the token won’t work. The token must match the email in accounts.conf.

Verify and Restart

Verify Tokens Work

# Outlook (returns long token string)
~/atelier/_projects/personal/email-config/scripts/oauth2-token.sh outlook

# Gmail (returns token starting with ya29.)
~/atelier/_projects/personal/email-config/scripts/oauth2-token.sh gmail-primary
~/atelier/_projects/personal/email-config/scripts/oauth2-token.sh gmail-secondary

Restart aerc

pkill aerc && aerc

Files Involved

File Purpose

~/.config/aerc/accounts.conf

Account configuration (email, IMAP, SMTP settings)

~/.secrets/email/outlook-tokens.json.age

Encrypted OAuth tokens for Outlook

~/.secrets/email/gmail-primary-tokens.json.age

Encrypted OAuth tokens for Gmail primary

~/.secrets/email/gmail-secondary-tokens.json.age

Encrypted OAuth tokens for Gmail secondary

~/atelier/_projects/personal/email-config/scripts/setup-oauth2.sh

Initial OAuth authorization flow

~/atelier/_projects/personal/email-config/scripts/oauth2-token.sh

Token refresh script (called by aerc)

Troubleshooting

Token works but aerc rejects it

Cause: You authorized the wrong Gmail account in the browser.

Fix: Re-run setup-oauth2.sh and ensure you’re logged into the correct account before authorizing.

"invalid_grant" persists after re-auth

  1. Check Google Cloud Console for app status

  2. Revoke all tokens: myaccount.google.com/permissions

  3. Re-authorize from scratch

Script can’t find token file

Check the token file exists and is readable:

ls -la ~/.secrets/email/*.age

If you renamed accounts, ensure token files match: - gmail-primary-tokens.json.age - gmail-secondary-tokens.json.age

Quick Reference

AGE_KEY=~/.secrets/.metadata/keys/master.age.key

# Check all token expiry dates
for acct in outlook gmail-primary gmail-secondary; do
  echo "=== $acct ==="
  age -d -i $AGE_KEY ~/.secrets/email/${acct}-tokens.json.age 2>/dev/null | \
    jq -r '"Expires: \(.expires_at | todate)"' || echo "No token file"
done

# View full token JSON for an account
age -d -i $AGE_KEY ~/.secrets/email/outlook-tokens.json.age | jq .

# Extract credentials (Gmail - needs both)
age -d -i $AGE_KEY ~/.secrets/email/gmail-primary-tokens.json.age | \
  jq -r '"Client ID: \(.client_id)\nClient Secret: \(.client_secret)"'

# Extract client ID only (Outlook - no secret needed)
age -d -i $AGE_KEY ~/.secrets/email/outlook-tokens.json.age | jq -r '.client_id'

# Test token refresh
~/atelier/_projects/personal/email-config/scripts/oauth2-token.sh outlook
~/atelier/_projects/personal/email-config/scripts/oauth2-token.sh gmail-primary

# Full re-auth
~/atelier/_projects/personal/email-config/scripts/setup-oauth2.sh outlook
~/atelier/_projects/personal/email-config/scripts/setup-oauth2.sh gmail-primary

# Restart aerc
pkill aerc && aerc

jq Patterns Used

Pattern Purpose

jq -r '.client_id'

Extract single field as raw string

jq -r '"Client ID: \(.client_id)"'

String interpolation with field value

jq -r '"\(.expires_at | todate)"'

Convert Unix timestamp to ISO date

jq '{account: "name", expires: .expires_at}'

Build new JSON object from fields