Skip to content

feat: oauth authentication#320

Open
gabrielmfern wants to merge 12 commits into
mainfrom
feat/oauth
Open

feat: oauth authentication#320
gabrielmfern wants to merge 12 commits into
mainfrom
feat/oauth

Conversation

@gabrielmfern
Copy link
Copy Markdown
Member

@gabrielmfern gabrielmfern commented Jun 5, 2026

this will only work with the staging api for now, but that's fine. the cleint id is hard coded, and, for consistency, I think we should ensure this exact client id also used in the production oauth client for the CLI

I've verified this does indeed work with the non-sending apis


Summary by cubic

Adds OAuth authentication to the CLI with a browser-based PKCE flow and automatic token refresh. API keys remain supported; the CLI now resolves either an API key or an OAuth grant across commands with improved scope-to-permission handling.

  • New Features

    • resend login: "Login with Resend" opens the browser, runs a local callback server with state verification and timeout, exchanges the code, and saves the grant per profile.
    • OAuth grants are stored with access/refresh expiries (access expiry from JWT exp), refreshed automatically, and respect RESEND_BASE_URL; client ID is set in OAUTH_CLIENT_ID.
    • Unified resolver resolveAuthentication returns either an API key or an OAuth grant; createClient/requireClient use the right token.
    • Permission checks map OAuth scopes (full_access, emails:send) to CLI permissions; error messages refer to "credentials" not just keys.
    • whoami and doctor display OAuth details, mask tokens, and skip key-only validation paths; whoami does not refresh expired access tokens.
    • When replacing a secure-storage API key with OAuth, the old keychain secret is deleted to avoid shadowing the new grant.
  • Migration

    • No action required; existing API keys keep working.
    • The credentials file now stores a type per profile and supports OAuth grants; legacy profiles are read and migrated in memory.
    • Secure-storage API keys are removed automatically if overwritten by an OAuth grant.
    • To use OAuth, run resend login and choose "Login with Resend".

Written for commit 6346679. Summary will update on new commits.

Review in cubic

gabrielmfern and others added 6 commits June 5, 2026 12:44
Signed-off-by: Gabriel Miranda <gabrielmfern@outlook.com>
Signed-off-by: Gabriel Miranda <gabrielmfern@outlook.com>
Signed-off-by: Gabriel Miranda <gabrielmfern@outlook.com>
Signed-off-by: Gabriel Miranda <gabrielmfern@outlook.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Gabriel Miranda <gabrielmfern@outlook.com>
Signed-off-by: Gabriel Miranda <gabrielmfern@outlook.com>
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 6 files (changes from recent commits).

Requires human review: This PR introduces a new OAuth authentication flow with PKCE, token refresh, and credential migration across multiple commands and core libraries, which is a high-risk change to critical authentication logic that requires human review for security, correctness, and integration with existing API key

Re-trigger cubic

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

7 issues found

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

Comment thread src/lib/oauth.ts
Comment thread src/lib/config.ts
Comment thread src/lib/client.ts Outdated
Comment thread src/lib/oauth.ts
Comment thread src/commands/whoami.ts
Comment thread src/commands/auth/login.ts Outdated
Comment thread src/commands/auth/login.ts Outdated
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 1 file (changes from recent commits).

Requires human review: Auto-approval blocked by 6 unresolved issues from previous reviews.

Re-trigger cubic

Signed-off-by: Gabriel Miranda <gabrielmfern@outlook.com>
Signed-off-by: Gabriel Miranda <gabrielmfern@outlook.com>
Comment thread src/lib/oauth.ts
export const OAUTH_CLIENT_ID = '7136aa0b-625c-4c9c-8820-e9784c8eb141';

export function getJwtExp(token: string): number {
const payload = token.split('.')[1];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be safer to validate the token structure here and throw in case there's anything wrong with it, otherwise users will have inconsistent states later

Signed-off-by: Gabriel Miranda <gabrielmfern@outlook.com>
Comment thread src/lib/config.ts
}
if (entry?.type === 'oauth_grant') {
const { access_token, scope } = await refreshOAuthGrant(entry, profile);
return { type: 'oauth_grant', access_token, profile, scope };
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if this fails, i.e. the user is logged out?
doctor and whoami call this, there's no error handling, so if I understood correctly, a user checking their expired auth would get unhandled errors instead of a message to login

Comment thread src/commands/whoami.ts
const globalOpts = cmd.optsWithGlobals() as GlobalOpts;
const profileFlag = globalOpts.profile;
const resolved = await resolveApiKeyAsync(globalOpts.apiKey, profileFlag);
const resolved = await resolveAuthentication(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

whoami's help message says it's a local command, but now it hits the network, we should clear that - or not call the actual auth here at all.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added a treatment for that, can you check again?

Signed-off-by: Gabriel Miranda <gabrielmfern@outlook.com>
Comment thread src/lib/config.ts

const updatedProfiles = {
...creds.profiles,
[profile]: { type: 'oauth_grant' as const, ...grant },
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the plain text credentials file is just supposed to store references for the name the user chose for their profile and the secure storage data; we shouldn't store the grand in plain text, it should go to secure storage

Comment thread src/lib/config.ts

function migrateRawProfile(raw: Record<string, unknown>): Profile {
if (raw.type === 'oauth_grant') {
return raw as OAuthGrant;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is plain text; maybe we should validate this format here?

Comment thread src/lib/oauth.ts
);
}

const data = (await response.json()) as {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it possible to have response.ok AND not have a valid token here? wondering if we should validate the schema to be safe

Comment thread src/lib/oauth.ts
res.end(
'<!doctype html><html><head><title>Resend CLI</title></head>' +
'<body style="font-family:system-ui;text-align:center;padding:2rem">' +
'<h2>Authentication complete</h2>' +
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we always choose "authentication complete" regardless of the code/state/error?

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 5 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/lib/oauth.ts">

<violation number="1" location="src/lib/oauth.ts:59">
P2: Unconditionally using `refresh_token_expires_in` can persist `NaN` and break refresh-expiry handling when the field is omitted.</violation>
</file>

<file name="src/lib/config.ts">

<violation number="1" location="src/lib/config.ts:543">
P2: This delete assumes every `api_key` profile in a `secure_storage` file has a keychain secret, so OAuth login can fail for preserved file-backed profiles.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

Comment thread src/lib/oauth.ts
};

const newAccessTokenExpiresAt = getJwtExp(data.access_token);
const newRefreshTokenExpiresAt = nowSeconds + data.refresh_token_expires_in;
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Jun 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Unconditionally using refresh_token_expires_in can persist NaN and break refresh-expiry handling when the field is omitted.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/lib/oauth.ts, line 59:

<comment>Unconditionally using `refresh_token_expires_in` can persist `NaN` and break refresh-expiry handling when the field is omitted.</comment>

<file context>
@@ -52,13 +52,11 @@ export async function refreshOAuthGrant(
-  const newRefreshTokenExpiresAt = data.refresh_token_expires_in
-    ? nowSeconds + data.refresh_token_expires_in
-    : grant.refresh_token_expires_at;
+  const newRefreshTokenExpiresAt = nowSeconds + data.refresh_token_expires_in;
 
   await storeOAuthGrant(
</file context>
Fix with cubic

Comment thread src/lib/config.ts
) {
const backend = await getCredentialBackend();
if (backend.isSecure) {
const deleted = await backend.delete(SERVICE_NAME, profile);
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Jun 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This delete assumes every api_key profile in a secure_storage file has a keychain secret, so OAuth login can fail for preserved file-backed profiles.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/lib/config.ts, line 543:

<comment>This delete assumes every `api_key` profile in a `secure_storage` file has a keychain secret, so OAuth login can fail for preserved file-backed profiles.</comment>

<file context>
@@ -522,6 +531,24 @@ export async function storeOAuthGrant(
+    ) {
+      const backend = await getCredentialBackend();
+      if (backend.isSecure) {
+        const deleted = await backend.delete(SERVICE_NAME, profile);
+        if (!deleted) {
+          throw new Error(
</file context>
Fix with cubic

Comment thread src/lib/oauth.ts
}

const baseUrl = process.env.RESEND_BASE_URL ?? 'https://api.resend.com';
const response = await fetch(`${baseUrl}/oauth/token`, {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Our other direct fetch calls use AbortSignal.timeout to prevent them from hanging?
I mean adding them to every fetch call added here.
For example

  let response: Response;
  try {
    response = await fetch(`${baseUrl}/oauth/token`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'refresh_token',
        client_id: OAUTH_CLIENT_ID,
        refresh_token: grant.refresh_token,
      }),
      signal: AbortSignal.timeout(30_000),
    });
  } catch (err) {
    if (err instanceof Error && err.name === 'TimeoutError') {
      throw new Error(
        'Token refresh timed out after 30s. Check your connection and try again.',
      );
    }
    throw new Error(
      'Could not reach the Resend API to refresh your session. Check your connection and try again.',
    );
  }

  if (!response.ok) {
    throw new Error(
      `Token refresh failed (${response.status}). Please run \`resend login\` again.`,
    );
  }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants