feat: oauth authentication#320
Conversation
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>
There was a problem hiding this comment.
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
There was a problem hiding this comment.
7 issues found
Tip: instead of fixing issues one by one fix them all with cubic
Re-trigger cubic
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
There was a problem hiding this comment.
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>
| export const OAUTH_CLIENT_ID = '7136aa0b-625c-4c9c-8820-e9784c8eb141'; | ||
|
|
||
| export function getJwtExp(token: string): number { | ||
| const payload = token.split('.')[1]; |
There was a problem hiding this comment.
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>
| } | ||
| if (entry?.type === 'oauth_grant') { | ||
| const { access_token, scope } = await refreshOAuthGrant(entry, profile); | ||
| return { type: 'oauth_grant', access_token, profile, scope }; |
There was a problem hiding this comment.
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
| const globalOpts = cmd.optsWithGlobals() as GlobalOpts; | ||
| const profileFlag = globalOpts.profile; | ||
| const resolved = await resolveApiKeyAsync(globalOpts.apiKey, profileFlag); | ||
| const resolved = await resolveAuthentication( |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
added a treatment for that, can you check again?
Signed-off-by: Gabriel Miranda <gabrielmfern@outlook.com>
|
|
||
| const updatedProfiles = { | ||
| ...creds.profiles, | ||
| [profile]: { type: 'oauth_grant' as const, ...grant }, |
There was a problem hiding this comment.
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
|
|
||
| function migrateRawProfile(raw: Record<string, unknown>): Profile { | ||
| if (raw.type === 'oauth_grant') { | ||
| return raw as OAuthGrant; |
There was a problem hiding this comment.
this is plain text; maybe we should validate this format here?
| ); | ||
| } | ||
|
|
||
| const data = (await response.json()) as { |
There was a problem hiding this comment.
is it possible to have response.ok AND not have a valid token here? wondering if we should validate the schema to be safe
| 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>' + |
There was a problem hiding this comment.
should we always choose "authentication complete" regardless of the code/state/error?
There was a problem hiding this comment.
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
| }; | ||
|
|
||
| const newAccessTokenExpiresAt = getJwtExp(data.access_token); | ||
| const newRefreshTokenExpiresAt = nowSeconds + data.refresh_token_expires_in; |
There was a problem hiding this comment.
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>
| ) { | ||
| const backend = await getCredentialBackend(); | ||
| if (backend.isSecure) { | ||
| const deleted = await backend.delete(SERVICE_NAME, profile); |
There was a problem hiding this comment.
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>
| } | ||
|
|
||
| const baseUrl = process.env.RESEND_BASE_URL ?? 'https://api.resend.com'; | ||
| const response = await fetch(`${baseUrl}/oauth/token`, { |
There was a problem hiding this comment.
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.`,
);
}
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.exp), refreshed automatically, and respectRESEND_BASE_URL; client ID is set inOAUTH_CLIENT_ID.resolveAuthenticationreturns either an API key or an OAuth grant;createClient/requireClientuse the right token.full_access,emails:send) to CLI permissions; error messages refer to "credentials" not just keys.whoamianddoctordisplay OAuth details, mask tokens, and skip key-only validation paths;whoamidoes not refresh expired access tokens.Migration
typeper profile and supports OAuth grants; legacy profiles are read and migrated in memory.resend loginand choose "Login with Resend".Written for commit 6346679. Summary will update on new commits.