Skip to content

OAuth rework#437

Open
augustuswm wants to merge 86 commits into
mainfrom
oauth-rework
Open

OAuth rework#437
augustuswm wants to merge 86 commits into
mainfrom
oauth-rework

Conversation

@augustuswm
Copy link
Copy Markdown
Collaborator

@augustuswm augustuswm commented May 7, 2026

A bunch of work that restructures how we do OAuth.

  1. Adds support for PKCE (Proof Key for Code Exchange) and secret-less code flows
  2. Adds Zendesk as a backend IdP
  3. Adds support for acquiring the underlying IdP access token that is generated (gated behind a permission)
  4. Introduces a crate v-cli-sdk with prebuilt commands for authentication and configuration that v-api based CLI applications can embed
  5. Introduces PKCE based code flow for CLI authentication
  6. A number of spec adherence issues fixed

@augustuswm
Copy link
Copy Markdown
Collaborator Author

augustuswm commented May 15, 2026

Ran through a number of iterations here and I think this is ready for final review.

@augustuswm augustuswm requested a review from notpeter May 15, 2026 22:06
Copy link
Copy Markdown
Contributor

@notpeter notpeter left a comment

Choose a reason for hiding this comment

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

I took a stab at reviewing this. Most of my comments are style-related, feel free to apply/ignore/fix later/never/etc.

Oh yeah and the date/ are wrong on migrations (note 2026).
2025-01-01, 2025-01-02, 2025-05-18 -> 2026-05-18_000001 etc.

Agent also identified a potential cli deadlock that figured I'd mention, which we're unlikely to hit, but would by definition be difficult to debug (no error message).

deadlock on cli errors

The CLI PKCE callback path can deadlock on error callbacks. The proxy consumes the single-use callback before invoking it, but the handler only sends on token_tx after successful parsing and code exchange. If the browser
returns error=access_denied, a malformed callback, or a CSRF mismatch, the handler returns Err without notifying the waiting login task, and the caller remains blocked on token_rx indefinitely.

Suggested fix: make the callback always complete the one-shot, including failure cases. The simplest approach is to catch all callback errors inside the closure and send Err(...) through token_tx before returning an HTTP
response to the browser. Optionally, also handle error callbacks explicitly and show a user-friendly failure page instead of treating them as missing code.

Comment thread v-api/src/context/auth.rs
) -> Result<Self, AppError> {
let signers = signers.into_iter().map(Arc::new).collect::<Vec<_>>();
let verifiers = verifiers.into_iter().map(Arc::new).collect::<Vec<_>>();
additional_permissions.extend_from_slice(&[
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.

style: sort these alphabetically

Comment thread v-api/src/context/mod.rs
/// be modified or deleted via the API. They do not support activation limits, they
/// fire unconditionally whenever their rule matches.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EphemeralMapperConfig {
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.

style: I'm not sure Ephemeral is the right word here.
Maybe Static / Fixed rather than ephemeral?
I dunno.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yeah, struggled trying to find a name for them. Certainly up for renaming. Originally they were static, but I wanted something that could convey that they are never persisted and are only an in memory construct. I'll give this some more thought.

Comment thread v-api/src/context/mod.rs
mappers: Vec<EphemeralMapperConfig>,
#[cfg(feature = "sagas")]
saga: Option<(TypedUuid<SagaExecNodeId>, Option<Logger>)>,
additional_builtin_permissions: Vec<T>,
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.

style: Perhaps worth adding a doc comment here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Thanks for the note. I need to rework this actually. Given some permission changes, this needs to change as well.

Comment thread v-api/src/endpoints/login/magic_link/client.rs Outdated
Comment thread v-api/src/endpoints/login/oauth/flow/code.rs
Comment thread v-cli-sdk/src/cmd/auth/login.rs Outdated
Comment thread v-api/src/endpoints/login/oauth/flow/device_token.rs Outdated
provider: &impl CliOAuthProviderInfo,
details: &DeviceAuthorizationResponse,
) -> Result<DeviceTokenResponse> {
let interval = Duration::from_secs(details.interval.unwrap_or(DEFAULT_POLL_INTERVAL_SECS));
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.

nit: What happens if this is zero? Maybe:

let interval = Duration::from_secs(
    details.interval.map(NonZeroU64::get).unwrap_or(DEFAULT_POLL_INTERVAL_SECS),
);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I think technically that would poll as fast as it reasonably can, but that is really the responsibility of the server to define. I think per the spec we are supposed to honor the servers request, and use 5 seconds in the even it is omitted.

Comment thread v-api/src/endpoints/login/oauth/flow/device_token.rs Outdated

// Check expiration - An attempt without an expiration is by default
// expired.
// TODO: Change expires_at to be non-optional. A login attempt should always
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 now a reasonable time to have a migration that sets a default value (0) for any existing rows and updates the database to be non-nullable and drops the Option<> from expired_at in LoginAttemptModel and LoginAttempt?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I think I want to put that into a separate PR at least. I'm still not 100% on if this should be an option or not.

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