diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 5f3de4096..aa7705a7c 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -2806,7 +2806,26 @@ impl Application { ) .await; - Identity::user(identity_result?) + match identity_result { + Ok(user_identity) => Identity::user(user_identity), + Err(error) => { + // For authentication errors, store them in Identity::Unknown so that + // getUserIdentityDebug can access and log them instead of failing the + // request + if let Some(error_metadata) = error.downcast_ref::() { + if matches!(error_metadata.code, errors::ErrorCode::Unauthenticated) { + return Ok(Identity::Unknown(Some(error_metadata.clone()))); + } + } + // For other errors (non-authentication), propagate them normally + return Err(error); + }, + } + }, + AuthenticationToken::PlaintextUser(token) => { + // For plaintext authentication, create a PlaintextUser identity + // The server is responsible for validating the token + Identity::PlaintextUser(token) }, AuthenticationToken::None => Identity::Unknown(None), }; diff --git a/crates/authentication/src/lib.rs b/crates/authentication/src/lib.rs index c14e209de..9a23de36e 100644 --- a/crates/authentication/src/lib.rs +++ b/crates/authentication/src/lib.rs @@ -131,6 +131,7 @@ pub fn token_to_authorization_header(token: AuthenticationToken) -> anyhow::Resu None => Ok(Some(format!("Convex {key}"))), }, AuthenticationToken::User(key) => Ok(Some(format!("Bearer {key}"))), + AuthenticationToken::PlaintextUser(key) => Ok(Some(format!("Bearer {key}"))), AuthenticationToken::None => Ok(None), } } diff --git a/crates/convex/sync_types/src/json.rs b/crates/convex/sync_types/src/json.rs index d0b4dc854..f5146def6 100644 --- a/crates/convex/sync_types/src/json.rs +++ b/crates/convex/sync_types/src/json.rs @@ -155,6 +155,9 @@ enum AuthenticationTokenJson { User { value: String, }, + PlaintextUser { + value: String, + }, None, } @@ -282,6 +285,13 @@ impl TryFrom for JsonValue { base_version, token: AuthenticationTokenJson::User { value }, }, + ClientMessage::Authenticate { + base_version, + token: AuthenticationToken::PlaintextUser(value), + } => ClientMessageJson::Authenticate { + base_version, + token: AuthenticationTokenJson::PlaintextUser { value }, + }, ClientMessage::Authenticate { base_version, token: AuthenticationToken::None, @@ -374,6 +384,9 @@ impl TryFrom for ClientMessage { ) }, AuthenticationTokenJson::User { value } => AuthenticationToken::User(value), + AuthenticationTokenJson::PlaintextUser { value } => { + AuthenticationToken::PlaintextUser(value) + }, AuthenticationTokenJson::None => AuthenticationToken::None, }, }, diff --git a/crates/convex/sync_types/src/types.rs b/crates/convex/sync_types/src/types.rs index d107c8b05..d0870e0db 100644 --- a/crates/convex/sync_types/src/types.rs +++ b/crates/convex/sync_types/src/types.rs @@ -252,6 +252,8 @@ pub enum AuthenticationToken { Admin(String, Option), /// OpenID Connect JWT User(String), + /// Plaintext authentication token (no JWT validation) + PlaintextUser(String), #[default] /// Logged out. None, diff --git a/crates/isolate/src/environment/udf/async_syscall.rs b/crates/isolate/src/environment/udf/async_syscall.rs index f226dcbaf..4d50bf57a 100644 --- a/crates/isolate/src/environment/udf/async_syscall.rs +++ b/crates/isolate/src/environment/udf/async_syscall.rs @@ -696,6 +696,12 @@ impl> DatabaseSyscallsV1 { "1.0/getUserIdentity" => { Box::pin(Self::get_user_identity(provider, args)).await }, + "1.0/getUserIdentityDebug" => { + Box::pin(Self::get_user_identity_debug(provider, args)).await + }, + "1.0/getUserIdentityInsecure" => { + Box::pin(Self::get_user_identity_insecure(provider, args)).await + }, // Storage "1.0/storageDelete" => Box::pin(Self::storage_delete(provider, args)).await, "1.0/storageGetMetadata" => { @@ -788,6 +794,60 @@ impl> DatabaseSyscallsV1 { Ok(JsonValue::Null) } + #[convex_macro::instrument_future] + async fn get_user_identity_debug( + provider: &mut P, + _args: JsonValue, + ) -> anyhow::Result { + provider.observe_identity()?; + let component = provider.component()?; + let tx = provider.tx()?; + let user_identity = tx.user_identity(); + + if !component.is_root() { + log_component_get_user_identity(user_identity.is_some()); + } + + // If we have a valid user identity, return it + if let Some(user_identity) = user_identity { + return user_identity.try_into(); + } + + // If no user identity, check if we have error details from JWT validation + let identity = tx.identity(); + if let keybroker::Identity::Unknown(Some(error_metadata)) = identity { + // Create a structured error response with details for debugging + let error_response = json!({ + "error": { + "code": error_metadata.short_msg, + "message": error_metadata.msg, + "details": "JWT validation failed. Check your token format, expiration, issuer, and audience claims." + } + }); + return Ok(error_response); + } + + // No identity provided (not an error case) + Ok(JsonValue::Null) + } + + #[convex_macro::instrument_future] + async fn get_user_identity_insecure( + provider: &mut P, + _args: JsonValue, + ) -> anyhow::Result { + let tx = provider.tx()?; + let identity = tx.identity(); + + // Return the plaintext token if this is a PlaintextUser identity + if let keybroker::Identity::PlaintextUser(token) = identity { + return Ok(JsonValue::String(token.clone())); + } + + // Return null for any other identity type (including regular User identities) + Ok(JsonValue::Null) + } + #[convex_macro::instrument_future] async fn storage_generate_upload_url( provider: &mut P, diff --git a/crates/isolate/src/tests/auth_debug.rs b/crates/isolate/src/tests/auth_debug.rs new file mode 100644 index 000000000..59ac199e6 --- /dev/null +++ b/crates/isolate/src/tests/auth_debug.rs @@ -0,0 +1,246 @@ +use common::{ + assert_obj, + types::MemberId, + value::ConvexValue, +}; +use keybroker::{ + testing::TestUserIdentity, + AdminIdentity, + Identity, + UserIdentity, +}; +use must_let::must_let; +use runtime::testing::TestRuntime; + +use crate::test_helpers::UdfTest; + +#[convex_macro::test_runtime] +async fn test_get_user_identity_debug_with_plaintext_user(rt: TestRuntime) -> anyhow::Result<()> { + UdfTest::run_test_with_isolate2(rt, async move |t| { + // Test that getUserIdentityDebug works with regular user identity + let identity = Identity::user(UserIdentity::test()); + let (result, outcome) = t + .query_outcome("auth:getUserIdentityDebug", assert_obj!(), identity.clone()) + .await?; + + // Should return the user identity, not an error + must_let!(let ConvexValue::Object(obj) = result); + assert!(obj.get("name").is_some()); + assert!(outcome.observed_identity); + + // Test with PlaintextUser identity - should return null (no JWT to debug) + let plaintext_identity = Identity::PlaintextUser("test-plaintext-token".to_string()); + let (result, outcome) = t + .query_outcome( + "auth:getUserIdentityDebug", + assert_obj!(), + plaintext_identity, + ) + .await?; + + assert_eq!(result, ConvexValue::Null); + assert!(outcome.observed_identity); + + Ok(()) + }) + .await +} + +#[convex_macro::test_runtime] +async fn test_get_user_identity_insecure_with_different_identities( + rt: TestRuntime, +) -> anyhow::Result<()> { + UdfTest::run_test_with_isolate2(rt, async move |t| { + // Test with PlaintextUser identity - should return the plaintext token + let plaintext_token = "my-test-plaintext-token-12345"; + let plaintext_identity = Identity::PlaintextUser(plaintext_token.to_string()); + let (result, outcome) = t + .query_outcome( + "auth:getUserIdentityInsecure", + assert_obj!(), + plaintext_identity, + ) + .await?; + + must_let!(let ConvexValue::String(token) = result); + assert_eq!(&*token, plaintext_token); + assert!(outcome.observed_identity == false); + + // Test with regular User identity - should return null + let user_identity = Identity::user(UserIdentity::test()); + let (result, outcome) = t + .query_outcome("auth:getUserIdentityInsecure", assert_obj!(), user_identity) + .await?; + + assert_eq!(result, ConvexValue::Null); + assert!(outcome.observed_identity == false); + + // Test with System identity - should return null + let system_identity = Identity::system(); + let (result, outcome) = t + .query_outcome( + "auth:getUserIdentityInsecure", + assert_obj!(), + system_identity, + ) + .await?; + + assert_eq!(result, ConvexValue::Null); + assert!(outcome.observed_identity == false); + + // Test with Admin identity - should return null + let admin_identity = Identity::InstanceAdmin(AdminIdentity::new_for_test_only( + "test-admin-key".to_string(), + MemberId(1), + )); + let (result, outcome) = t + .query_outcome( + "auth:getUserIdentityInsecure", + assert_obj!(), + admin_identity, + ) + .await?; + + assert_eq!(result, ConvexValue::Null); + assert!(outcome.observed_identity == false); + + // Test with Unknown identity - should return null + let unknown_identity = Identity::Unknown(None); + let (result, outcome) = t + .query_outcome( + "auth:getUserIdentityInsecure", + assert_obj!(), + unknown_identity, + ) + .await?; + + assert_eq!(result, ConvexValue::Null); + assert!(outcome.observed_identity == false); + + Ok(()) + }) + .await +} + +#[convex_macro::test_runtime] +async fn test_plaintext_user_admin_access_restriction(rt: TestRuntime) -> anyhow::Result<()> { + UdfTest::run_test_with_isolate2(rt, async move |t| { + // Test that PlaintextUser identity cannot access admin-protected functions + let plaintext_identity = Identity::PlaintextUser("admin-wannabe-token".to_string()); + + // This test would verify that PlaintextUser identities are properly rejected + // by the must_be_admin_internal function changes + let (outcome, _token) = t + .raw_query( + "auth:testAdminAccess", + vec![ConvexValue::Object(assert_obj!())], + plaintext_identity, + None, + ) + .await?; + + // Should fail with admin access error + assert!(outcome.result.is_err()); + let error = outcome.result.unwrap_err(); + let error_str = error.to_string(); + assert!(error_str.contains("BadDeployKey") || error_str.contains("invalid")); + + // Compare with regular admin identity which should succeed + let admin_identity = Identity::InstanceAdmin(AdminIdentity::new_for_test_only( + "valid-admin-key".to_string(), + MemberId(1), + )); + + // This should succeed for admin identities + let (admin_outcome, _token) = t + .raw_query( + "auth:testAdminAccess", + vec![ConvexValue::Object(assert_obj!())], + admin_identity, + None, + ) + .await?; + + // Admin should have access + assert!(admin_outcome.result.is_ok()); + + Ok(()) + }) + .await +} + +#[convex_macro::test_runtime] +async fn test_plaintext_user_identity_creation_and_handling(rt: TestRuntime) -> anyhow::Result<()> { + UdfTest::run_test_with_isolate2(rt, async move |t| { + let test_token = "test-plaintext-auth-token-xyz"; + let plaintext_identity = Identity::PlaintextUser(test_token.to_string()); + + // Test that PlaintextUser identity is properly handled in queries + let (result, outcome) = t + .query_outcome( + "auth:getIdentityType", + assert_obj!(), + plaintext_identity.clone(), + ) + .await?; + + // Should indicate it's a PlaintextUser identity + must_let!(let ConvexValue::String(identity_type) = result); + assert_eq!(&*identity_type, "PlaintextUser"); + assert!(outcome.observed_identity); + + // Test that getUserIdentityInsecure returns the correct token + let (token_result, _) = t + .query_outcome( + "auth:getUserIdentityInsecure", + assert_obj!(), + plaintext_identity, + ) + .await?; + + must_let!(let ConvexValue::String(returned_token) = token_result); + assert_eq!(&*returned_token, test_token); + + Ok(()) + }) + .await +} + +#[convex_macro::test_runtime] +async fn test_get_user_identity_debug_error_scenarios(rt: TestRuntime) -> anyhow::Result<()> { + UdfTest::run_test_with_isolate2(rt, async move |t| { + // Test getUserIdentityDebug with Unknown identity containing error + let error_message = "JWT validation failed: token expired"; + let unknown_identity_with_error = Identity::Unknown(Some( + errors::ErrorMetadata::bad_request("InvalidJWT", error_message), + )); + + let (result, outcome) = t + .query_outcome( + "auth:getUserIdentityDebug", + assert_obj!(), + unknown_identity_with_error, + ) + .await?; + + // Should return structured error information + must_let!(let ConvexValue::Object(error_obj) = result); + assert!(error_obj.get("error").is_some()); + must_let!(let ConvexValue::Object(error_obj_inner) = error_obj.get("error").unwrap()); + assert!(error_obj_inner.get("code").is_some()); + assert!(error_obj_inner.get("message").is_some()); + assert!(outcome.observed_identity); + + // Test with Unknown identity without error - should return null + let unknown_identity = Identity::Unknown(None); + let (result, outcome) = t + .query_outcome("auth:getUserIdentityDebug", assert_obj!(), unknown_identity) + .await?; + + assert_eq!(result, ConvexValue::Null); + assert!(outcome.observed_identity); + + Ok(()) + }) + .await +} diff --git a/crates/isolate/src/tests/mod.rs b/crates/isolate/src/tests/mod.rs index 4f1af6b13..7282930b4 100644 --- a/crates/isolate/src/tests/mod.rs +++ b/crates/isolate/src/tests/mod.rs @@ -4,6 +4,7 @@ mod analyze; mod args_validation; mod r#async; mod auth; +mod auth_debug; mod backend_state; mod basic; mod creation_time; diff --git a/crates/keybroker/src/broker.rs b/crates/keybroker/src/broker.rs index e0a1e7517..fb0df0097 100644 --- a/crates/keybroker/src/broker.rs +++ b/crates/keybroker/src/broker.rs @@ -65,6 +65,7 @@ use pb::{ convex_identity::{ unchecked_identity::Identity as UncheckedIdentityProto, ActingUser, + PlaintextUserIdentity, UnknownIdentity, }, convex_keys::{ @@ -144,6 +145,8 @@ pub enum Identity { // ActingUser keeps track of the ID of the admin acting as a user, // and that user's fake attributes ActingUser(AdminIdentity, UserIdentityAttributes), + // PlaintextUser holds a plaintext authentication token for server-side validation + PlaintextUser(String), // Unknown(None) means no identity was provided. // Unknown(Some(error_message)) means an error occurred while parsing the identity. // We allow the request to go through, but keep the error to throw when code tries to @@ -159,6 +162,7 @@ impl From for AuthenticationToken { AuthenticationToken::Admin(identity.key, Some(user)) }, Identity::InstanceAdmin(identity) => AuthenticationToken::Admin(identity.key, None), + Identity::PlaintextUser(token) => AuthenticationToken::PlaintextUser(token), _ => AuthenticationToken::None, } } @@ -180,6 +184,11 @@ impl From for pb::convex_identity::UncheckedIdentity { attributes: Some(attributes.into()), }) }, + Identity::PlaintextUser(token) => { + UncheckedIdentityProto::PlaintextUserIdentity(PlaintextUserIdentity { + token: Some(token), + }) + }, Identity::Unknown(error_message) => UncheckedIdentityProto::Unknown(UnknownIdentity { error_message: error_message.map(|e| e.into()), }), @@ -216,6 +225,10 @@ impl Identity { attributes.ok_or_else(|| anyhow::anyhow!("Missing user attributes"))?; Ok(Identity::ActingUser(admin_identity, attributes.try_into()?)) }, + UncheckedIdentityProto::PlaintextUserIdentity(PlaintextUserIdentity { token }) => { + let token = token.ok_or_else(|| anyhow::anyhow!("Missing plaintext token"))?; + Ok(Identity::PlaintextUser(token)) + }, UncheckedIdentityProto::Unknown(UnknownIdentity { error_message }) => Ok( Identity::Unknown(error_message.map(|e| e.try_into()).transpose()?), ), @@ -252,6 +265,7 @@ impl From for InertIdentity { Identity::InstanceAdmin(i) => InertIdentity::InstanceAdmin(i.instance_name), Identity::System(_) => InertIdentity::System, Identity::Unknown(_) => InertIdentity::Unknown, + Identity::PlaintextUser(_) => InertIdentity::Unknown, Identity::User(user) => InertIdentity::User(user.attributes.token_identifier), Identity::ActingUser(identity, user) => match identity.principal { AdminIdentityPrincipal::Member(member_id) => { @@ -273,6 +287,7 @@ impl PartialEq for Identity { (Self::User(l), Self::User(r)) => { l.attributes.token_identifier == r.attributes.token_identifier }, + (Self::PlaintextUser(l), Self::PlaintextUser(r)) => l == r, (Self::Unknown(_), Self::Unknown(_)) => true, ( Self::ActingUser(l_admin_identity, l_attributes), @@ -281,6 +296,7 @@ impl PartialEq for Identity { (Self::InstanceAdmin(_), _) | (Self::System(_), _) | (Self::User(_), _) + | (Self::PlaintextUser(_), _) | (Self::Unknown(_), _) | (Self::ActingUser(..), _) => false, } @@ -297,6 +313,7 @@ impl Identity { Identity::Unknown(error_message) => { IdentityCacheKey::Unknown(error_message.map(|e| e.to_string())) }, + Identity::PlaintextUser(_) => IdentityCacheKey::Unknown(None), Identity::User(user) => IdentityCacheKey::User(user.attributes), // Identity of the impersonator not relevant for caching. Only the one being // impersonated. diff --git a/crates/local_backend/src/admin.rs b/crates/local_backend/src/admin.rs index 97f371261..79f20579f 100644 --- a/crates/local_backend/src/admin.rs +++ b/crates/local_backend/src/admin.rs @@ -67,7 +67,10 @@ fn must_be_admin_internal( let admin_identity = match identity { Identity::InstanceAdmin(admin_identity) => admin_identity, Identity::ActingUser(admin_identity, _user_identity_attributes) => admin_identity, - Identity::System(_) | Identity::User(_) | Identity::Unknown(_) => { + Identity::System(_) + | Identity::User(_) + | Identity::PlaintextUser(_) + | Identity::Unknown(_) => { return Err(bad_admin_key_error(identity.instance_name()).into()); }, }; diff --git a/crates/pb/protos/convex_identity.proto b/crates/pb/protos/convex_identity.proto index e7d183d75..3da956cb4 100644 --- a/crates/pb/protos/convex_identity.proto +++ b/crates/pb/protos/convex_identity.proto @@ -9,6 +9,7 @@ message AuthenticationToken { oneof identity { AdminAuthenticationToken admin = 1; string user = 2; + string plaintext_user = 4; google.protobuf.Empty none = 3; } } @@ -26,6 +27,7 @@ message UncheckedIdentity { UserIdentity user_identity = 3; ActingUser acting_user = 4; UnknownIdentity unknown = 5; + PlaintextUserIdentity plaintext_user_identity = 6; } } @@ -33,6 +35,10 @@ message UnknownIdentity { optional errors.ErrorMetadata error_message = 1; } +message PlaintextUserIdentity { + optional string token = 1; +} + message AdminIdentity { optional string instance_name = 1; optional string key = 3; diff --git a/crates/pb/src/authentication_token.rs b/crates/pb/src/authentication_token.rs index 28a63da27..4cd329cef 100644 --- a/crates/pb/src/authentication_token.rs +++ b/crates/pb/src/authentication_token.rs @@ -23,6 +23,9 @@ impl TryFrom for AuthenticationToke AuthenticationToken::Admin(key, acting_as) }, AuthenticationTokenProto::User(token) => AuthenticationToken::User(token), + AuthenticationTokenProto::PlaintextUser(token) => { + AuthenticationToken::PlaintextUser(token) + }, AuthenticationTokenProto::None(_) => AuthenticationToken::None, }; Ok(token) @@ -40,6 +43,9 @@ impl From for crate::convex_identity::AuthenticationToken { }) }, AuthenticationToken::User(token) => AuthenticationTokenProto::User(token), + AuthenticationToken::PlaintextUser(token) => { + AuthenticationTokenProto::PlaintextUser(token) + }, AuthenticationToken::None => AuthenticationTokenProto::None(()), }; Self { diff --git a/npm-packages/convex/src/browser/index.ts b/npm-packages/convex/src/browser/index.ts index f61eaf47f..0c0c9c7fd 100644 --- a/npm-packages/convex/src/browser/index.ts +++ b/npm-packages/convex/src/browser/index.ts @@ -23,6 +23,7 @@ export type { SubscribeOptions, ConnectionState, AuthTokenFetcher, + PlaintextAuthTokenFetcher, } from "./sync/client.js"; export type { ConvexClientOptions } from "./simple_client.js"; export { ConvexClient } from "./simple_client.js"; diff --git a/npm-packages/convex/src/browser/sync/authentication_manager.ts b/npm-packages/convex/src/browser/sync/authentication_manager.ts index c1d601416..a23a98e59 100644 --- a/npm-packages/convex/src/browser/sync/authentication_manager.ts +++ b/npm-packages/convex/src/browser/sync/authentication_manager.ts @@ -26,12 +26,33 @@ export type AuthTokenFetcher = (args: { forceRefreshToken: boolean; }) => Promise; +/** + * An async function returning a plaintext authentication token. + * + * The token will be sent directly to the server without any client-side + * validation or JWT processing. The server is responsible for validating + * the token. + * + * `forceRefreshToken` is `true` if the server rejected a previously + * returned token. + * + * @public + */ +export type PlaintextAuthTokenFetcher = (args: { + forceRefreshToken: boolean; +}) => Promise; + /** * What is provided to the client. */ type AuthConfig = { fetchToken: AuthTokenFetcher; onAuthChange: (isAuthenticated: boolean) => void; + mode: "jwt"; +} | { + fetchToken: PlaintextAuthTokenFetcher; + onAuthChange: (isAuthenticated: boolean) => void; + mode: "plaintext"; }; /** @@ -87,7 +108,7 @@ export class AuthenticationManager { // Shared by the BaseClient so that the auth manager can easily inspect it private readonly syncState: LocalSyncState; // Passed down by BaseClient, sends a message to the server - private readonly authenticate: (token: string) => IdentityVersion; + private readonly authenticate: (token: string, mode?: "jwt" | "plaintext") => IdentityVersion; private readonly stopSocket: () => Promise; private readonly tryRestartSocket: () => void; private readonly pauseSocket: () => void; @@ -102,7 +123,7 @@ export class AuthenticationManager { constructor( syncState: LocalSyncState, callbacks: { - authenticate: (token: string) => IdentityVersion; + authenticate: (token: string, mode?: "jwt" | "plaintext") => IdentityVersion; stopSocket: () => Promise; tryRestartSocket: () => void; pauseSocket: () => void; @@ -129,10 +150,29 @@ export class AuthenticationManager { fetchToken: AuthTokenFetcher, onChange: (isAuthenticated: boolean) => void, ) { + await this.setConfigInternal({ + fetchToken, + onAuthChange: onChange, + mode: "jwt", + }); + } + + async setConfigInsecure( + fetchToken: PlaintextAuthTokenFetcher, + onChange: (isAuthenticated: boolean) => void, + ) { + await this.setConfigInternal({ + fetchToken, + onAuthChange: onChange, + mode: "plaintext", + }); + } + + private async setConfigInternal(config: AuthConfig) { this.resetAuthState(); this._logVerbose("pausing WS for auth token fetch"); this.pauseSocket(); - const token = await this.fetchTokenAndGuardAgainstRace(fetchToken, { + const token = await this.fetchTokenAndGuardAgainstRace(config.fetchToken, { forceRefreshToken: false, }); if (token.isFromOutdatedConfig) { @@ -141,14 +181,14 @@ export class AuthenticationManager { if (token.value) { this.setAuthState({ state: "waitingForServerConfirmationOfCachedToken", - config: { fetchToken, onAuthChange: onChange }, + config, hasRetried: false, }); - this.authenticate(token.value); + this.authenticate(token.value, config.mode); } else { this.setAuthState({ state: "initialRefetch", - config: { fetchToken, onAuthChange: onChange }, + config, }); // Try again with `forceRefreshToken: true` await this.refetchToken(); @@ -258,7 +298,7 @@ export class AuthenticationManager { } if (token.value && this.syncState.isNewAuth(token.value)) { - this.authenticate(token.value); + this.authenticate(token.value, this.authState.config.mode); this.setAuthState({ state: "waitingForServerConfirmationOfFreshToken", config: this.authState.config, @@ -303,7 +343,7 @@ export class AuthenticationManager { token: token.value, config: this.authState.config, }); - this.authenticate(token.value); + this.authenticate(token.value, this.authState.config.mode); } else { this.setAuthState({ state: "notRefetching", @@ -329,6 +369,17 @@ export class AuthenticationManager { if (this.authState.state === "noAuth") { return; } + + // For plaintext authentication, we don't schedule automatic refreshes + if (this.authState.config.mode === "plaintext") { + this._logVerbose("plaintext auth mode, no scheduled token refresh"); + this.setAuthState({ + state: "notRefetching", + config: this.authState.config, + }); + return; + } + const decodedToken = this.decodeToken(token); if (!decodedToken) { // This is no longer really possible, because diff --git a/npm-packages/convex/src/browser/sync/client.ts b/npm-packages/convex/src/browser/sync/client.ts index cc563329f..a65802239 100644 --- a/npm-packages/convex/src/browser/sync/client.ts +++ b/npm-packages/convex/src/browser/sync/client.ts @@ -36,8 +36,9 @@ import { FunctionResult } from "./function_result.js"; import { AuthenticationManager, AuthTokenFetcher, + PlaintextAuthTokenFetcher, } from "./authentication_manager.js"; -export { type AuthTokenFetcher } from "./authentication_manager.js"; +export { type AuthTokenFetcher, type PlaintextAuthTokenFetcher } from "./authentication_manager.js"; import { getMarksReport, mark, MarkName } from "./metrics.js"; import { parseArgs, validateDeploymentUrl } from "../../common/index.js"; import { ConvexError } from "../../values/errors.js"; @@ -332,8 +333,10 @@ export class BaseConvexClient { this.authenticationManager = new AuthenticationManager( this.state, { - authenticate: (token) => { - const message = this.state.setAuth(token); + authenticate: (token, mode) => { + const message = mode === "plaintext" + ? this.state.setAuthInsecure(token) + : this.state.setAuth(token); this.webSocketManager.sendMessage(message); return message.baseVersion; }, @@ -631,6 +634,13 @@ export class BaseConvexClient { void this.authenticationManager.setConfig(fetchToken, onChange); } + setAuthInsecure( + fetchToken: PlaintextAuthTokenFetcher, + onChange: (isAuthenticated: boolean) => void, + ) { + void this.authenticationManager.setConfigInsecure(fetchToken, onChange); + } + hasAuth() { return this.state.hasAuth(); } diff --git a/npm-packages/convex/src/browser/sync/local_state.ts b/npm-packages/convex/src/browser/sync/local_state.ts index 4cb45e6e4..17c8d3939 100644 --- a/npm-packages/convex/src/browser/sync/local_state.ts +++ b/npm-packages/convex/src/browser/sync/local_state.ts @@ -43,6 +43,10 @@ export class LocalSyncState { value: string; impersonating?: UserIdentityAttributes | undefined; } + | { + tokenType: "PlaintextUser"; + value: string; + } | undefined; private readonly outstandingQueriesOlderThanRestart: Set; private outstandingAuthOlderThanRestart: boolean; @@ -200,6 +204,22 @@ export class LocalSyncState { }; } + setAuthInsecure(value: string): Authenticate { + this.auth = { + tokenType: "PlaintextUser", + value: value, + }; + const baseVersion = this.identityVersion; + if (!this.paused) { + this.identityVersion = baseVersion + 1; + } + return { + type: "Authenticate", + baseVersion: baseVersion, + ...this.auth, + }; + } + setAdminAuth( value: string, actingAs?: UserIdentityAttributes, diff --git a/npm-packages/convex/src/browser/sync/protocol.ts b/npm-packages/convex/src/browser/sync/protocol.ts index 87c9ac617..8ead66ea2 100644 --- a/npm-packages/convex/src/browser/sync/protocol.ts +++ b/npm-packages/convex/src/browser/sync/protocol.ts @@ -184,6 +184,12 @@ export type Authenticate = value: string; baseVersion: IdentityVersion; } + | { + type: "Authenticate"; + tokenType: "PlaintextUser"; + value: string; + baseVersion: IdentityVersion; + } | { type: "Authenticate"; tokenType: "None"; diff --git a/npm-packages/convex/src/react/auth_insecure.test.tsx b/npm-packages/convex/src/react/auth_insecure.test.tsx new file mode 100644 index 000000000..a5723320f --- /dev/null +++ b/npm-packages/convex/src/react/auth_insecure.test.tsx @@ -0,0 +1,315 @@ +/** + * @vitest-environment custom-vitest-environment.ts + */ +import { expect, vi, test, describe, beforeEach } from "vitest"; +import jwtEncode from "jwt-encode"; +import { + nodeWebSocket, + withInMemoryWebSocket, +} from "../browser/sync/client_node_test_helpers.js"; +import { ConvexReactClient, ConvexReactClientOptions } from "./index.js"; +import waitForExpect from "wait-for-expect"; +import { anyApi } from "../server/index.js"; +import { Long } from "../browser/long.js"; +import { + AuthError, + ClientMessage, + ServerMessage, +} from "../browser/sync/protocol.js"; + +const testReactClient = (address: string, options?: ConvexReactClientOptions) => + new ConvexReactClient(address, { + webSocketConstructor: nodeWebSocket, + unsavedChangesWarning: false, + ...options, + }); + +describe("setAuthInsecure functionality", () => { + test("setAuthInsecure establishes plaintext authentication", async () => { + await withInMemoryWebSocket(async ({ address, receive, send }) => { + const client = testReactClient(address); + + const plaintextToken = "my-plaintext-test-token-12345"; + const tokenFetcher = vi.fn(async () => plaintextToken); + const onAuthChange = vi.fn(); + + client.setAuthInsecure(tokenFetcher, onAuthChange); + + expect((await receive()).type).toEqual("Connect"); + expect((await receive()).type).toEqual("Authenticate"); + expect((await receive()).type).toEqual("ModifyQuerySet"); + + const querySetVersion = client.sync["remoteQuerySet"]["version"]; + + send({ + type: "Transition", + startVersion: querySetVersion, + endVersion: { + ...querySetVersion, + identity: 1, + }, + modifications: [], + }); + + await waitForExpect(() => { + expect(onAuthChange).toHaveBeenCalledTimes(1); + }); + await client.close(); + + expect(tokenFetcher).toHaveBeenCalledWith({ forceRefreshToken: false }); + expect(onAuthChange).toHaveBeenCalledWith(true); + }); + }); + + test("switching from setAuth to setAuthInsecure", async () => { + await withInMemoryWebSocket(async ({ address, receive, send }) => { + const client = testReactClient(address); + + // First set regular JWT auth + const jwtToken = jwtEncode( + { iat: 1234500, exp: 1244500, name: "User" }, + "secret", + ); + const jwtTokenFetcher = vi.fn(async () => jwtToken); + const jwtOnAuthChange = vi.fn(); + + client.setAuth(jwtTokenFetcher, jwtOnAuthChange); + + expect((await receive()).type).toEqual("Connect"); + assertAuthenticateMessage(await receive(), { + baseVersion: 0, + token: jwtToken, + tokenType: "User", + }); + expect((await receive()).type).toEqual("ModifyQuerySet"); + + const querySetVersion = client.sync["remoteQuerySet"]["version"]; + + send({ + type: "Transition", + startVersion: querySetVersion, + endVersion: { + ...querySetVersion, + identity: 1, + }, + modifications: [], + }); + + await waitForExpect(() => { + expect(jwtOnAuthChange).toHaveBeenCalledTimes(1); + }); + + // Now switch to plaintext auth + const plaintextToken = "plaintext-token-67890"; + const plaintextTokenFetcher = vi.fn(async () => plaintextToken); + const plaintextOnAuthChange = vi.fn(); + + client.setAuthInsecure(plaintextTokenFetcher, plaintextOnAuthChange); + + // Should receive Authenticate message with plaintext token + assertAuthenticateMessage(await receive(), { + baseVersion: 1, + token: plaintextToken, + tokenType: "PlaintextUser", + }); + + send({ + type: "Transition", + startVersion: { + ...querySetVersion, + identity: 1, + }, + endVersion: { + ...querySetVersion, + identity: 2, + }, + modifications: [], + }); + + await waitForExpect(() => { + expect(plaintextOnAuthChange).toHaveBeenCalledTimes(1); + }); + await client.close(); + + expect(plaintextTokenFetcher).toHaveBeenCalledWith({ + forceRefreshToken: false, + }); + expect(plaintextOnAuthChange).toHaveBeenCalledWith(true); + }); + }); + + test("clearAuth after setAuthInsecure", async () => { + await withInMemoryWebSocket(async ({ address, receive, send }) => { + const client = testReactClient(address); + + const plaintextToken = "test-plaintext-token"; + const tokenFetcher = vi.fn(async () => plaintextToken); + const onAuthChange = vi.fn(); + + client.setAuthInsecure(tokenFetcher, onAuthChange); + + expect((await receive()).type).toEqual("Connect"); + assertAuthenticateMessage(await receive(), { + baseVersion: 0, + token: plaintextToken, + tokenType: "PlaintextUser", + }); + expect((await receive()).type).toEqual("ModifyQuerySet"); + + const querySetVersion = client.sync["remoteQuerySet"]["version"]; + + send({ + type: "Transition", + startVersion: querySetVersion, + endVersion: { + ...querySetVersion, + identity: 1, + }, + modifications: [], + }); + + await waitForExpect(() => { + expect(onAuthChange).toHaveBeenCalledTimes(1); + }); + + // this doesn't result in onAuthChange being called. + client.clearAuth(); + + await waitForExpect(() => { + expect(onAuthChange).toHaveBeenCalledTimes(1); + }); + + await client.close(); + + expect(onAuthChange).toHaveBeenNthCalledWith(1, true); + }); + }); + + test("setAuthInsecure handles token refresh", async () => { + await withInMemoryWebSocket(async ({ address, receive, send, close }) => { + const client = testReactClient(address); + + let tokenCount = 0; + const tokenFetcher = vi.fn(async () => `plaintext-token-${++tokenCount}`); + const onAuthChange = vi.fn(); + + client.setAuthInsecure(tokenFetcher, onAuthChange); + + await assertReconnectWithAuth(receive, { + baseVersion: 0, + token: "plaintext-token-1", + tokenType: "PlaintextUser", + }); + + // Simulate auth error requiring token refresh + await simulateAuthError({ + send, + close, + authError: { + type: "AuthError", + error: "plaintext token expired", + baseVersion: 0, + authUpdateAttempted: true, + }, + }); + + // The client reconnects with a new token + await assertReconnectWithAuth(receive, { + baseVersion: 0, + token: "plaintext-token-2", + tokenType: "PlaintextUser", + }); + + const querySetVersion = client.sync["remoteQuerySet"]["version"]; + + send({ + type: "Transition", + startVersion: querySetVersion, + endVersion: { + ...querySetVersion, + identity: 1, + }, + modifications: [], + }); + + await waitForExpect(() => { + expect(onAuthChange).toHaveBeenCalledTimes(1); + }); + await client.close(); + + expect(tokenFetcher).toHaveBeenNthCalledWith(1, { + forceRefreshToken: false, + }); + expect(tokenFetcher).toHaveBeenNthCalledWith(2, { + forceRefreshToken: true, + }); + expect(onAuthChange).toHaveBeenCalledWith(true); + }); + }); + + test("setAuthInsecure fails when tokens cannot be fetched", async () => { + await withInMemoryWebSocket(async ({ address, receive }) => { + const client = testReactClient(address); + const tokenFetcher = vi.fn(async () => null); + const onAuthChange = vi.fn(); + + client.setAuthInsecure(tokenFetcher, onAuthChange); + + expect((await receive()).type).toEqual("Connect"); + expect((await receive()).type).toEqual("ModifyQuerySet"); + + await waitForExpect(() => { + expect(onAuthChange).toHaveBeenCalledTimes(1); + }); + await client.close(); + + expect(onAuthChange).toHaveBeenCalledWith(false); + }); + }); +}); + +function assertAuthenticateMessage( + message: ClientMessage, + expected: { + baseVersion: number; + token: string; + tokenType: "User" | "PlaintextUser"; + }, +) { + expect(message.type).toEqual("Authenticate"); + if (message.type !== "Authenticate") { + throw new Error("Expected an Authenticate message"); + } + expect(message.baseVersion).toEqual(expected.baseVersion); + expect(message.tokenType).toEqual(expected.tokenType); + if (message.tokenType === "User" || message.tokenType === "PlaintextUser") { + expect(message.value).toEqual(expected.token); + } +} + +async function assertReconnectWithAuth( + receive: () => Promise, + expectedAuth: { + baseVersion: number; + token: string; + tokenType: "User" | "PlaintextUser"; + }, +) { + expect((await receive()).type).toEqual("Connect"); + assertAuthenticateMessage(await receive(), expectedAuth); + expect((await receive()).type).toEqual("ModifyQuerySet"); +} + +async function simulateAuthError(args: { + send: (message: ServerMessage) => void; + close: () => void; + authError: AuthError; +}) { + args.send({ + type: "AuthError", + error: args.authError.error, + baseVersion: args.authError.baseVersion, + authUpdateAttempted: args.authError.authUpdateAttempted, + }); + args.close(); +} diff --git a/npm-packages/convex/src/react/client.ts b/npm-packages/convex/src/react/client.ts index c54c1339a..c3c52a256 100644 --- a/npm-packages/convex/src/react/client.ts +++ b/npm-packages/convex/src/react/client.ts @@ -5,6 +5,7 @@ import { convexToJson, Value } from "../values/index.js"; import { QueryJournal } from "../browser/sync/protocol.js"; import { AuthTokenFetcher, + PlaintextAuthTokenFetcher, BaseConvexClientOptions, ConnectionState, } from "../browser/sync/client.js"; @@ -338,6 +339,33 @@ export class ConvexReactClient { ); } + /** + * Set a plaintext authentication token to be used for subsequent queries and mutations. + * Unlike setAuth, this bypasses all JWT processing and sends the token directly to the server. + * The server is responsible for validating the token. + * `fetchToken` will only be called again if the server rejects the token. + * @param fetchToken - an async function returning a plaintext authentication token + * @param onChange - a callback that will be called when the authentication status changes + */ + setAuthInsecure( + fetchToken: PlaintextAuthTokenFetcher, + onChange?: (isAuthenticated: boolean) => void, + ) { + if (typeof fetchToken === "string") { + throw new Error( + "Passing a string to ConvexReactClient.setAuthInsecure is not supported, " + + "please pass an async function that returns the authentication token.", + ); + } + this.sync.setAuthInsecure( + fetchToken, + onChange ?? + (() => { + // Do nothing + }), + ); + } + /** * Clear the current authentication token if set. */ diff --git a/npm-packages/convex/src/react/index.ts b/npm-packages/convex/src/react/index.ts index 59910ce37..4c72ac8ce 100644 --- a/npm-packages/convex/src/react/index.ts +++ b/npm-packages/convex/src/react/index.ts @@ -62,7 +62,7 @@ */ export * from "./use_paginated_query.js"; export { useQueries, type RequestForQueries } from "./use_queries.js"; -export type { AuthTokenFetcher } from "../browser/sync/client.js"; +export type { AuthTokenFetcher, PlaintextAuthTokenFetcher } from "../browser/sync/client.js"; export * from "./auth_helpers.js"; export * from "./ConvexAuthState.js"; export * from "./hydration.js"; diff --git a/npm-packages/convex/src/server/authentication.ts b/npm-packages/convex/src/server/authentication.ts index 317319318..6a9278a79 100644 --- a/npm-packages/convex/src/server/authentication.ts +++ b/npm-packages/convex/src/server/authentication.ts @@ -149,6 +149,29 @@ export type UserIdentityAttributes = Omit< "tokenIdentifier" >; +/** + * Structured error information returned by getUserIdentityDebug() + * when JWT validation fails. + * + * @public + */ +export interface AuthError { + error: { + /** + * Short error code identifying the type of authentication failure. + */ + code: string; + /** + * Detailed error message explaining what went wrong. + */ + message: string; + /** + * Additional guidance for debugging the authentication issue. + */ + details: string; + }; +} + /** * An interface to access information about the currently authenticated user * within Convex query and mutation functions. @@ -165,4 +188,31 @@ export interface Auth { * + `throw` on HTTP Actions. */ getUserIdentity(): Promise; + + /** + * Get details about the currently authenticated user with debug information. + * + * Similar to getUserIdentity(), but when authentication fails, this method + * returns detailed error information instead of null to help debug JWT + * validation issues. + * + * @returns A promise that resolves to: + * + A {@link UserIdentity} if authentication succeeded + * + An {@link AuthError} with detailed error information if JWT validation failed + * + `null` if no authentication token was provided + */ + getUserIdentityDebug(): Promise; + + /** + * Get the plaintext token for PlaintextUser identities. + * + * **WARNING:** This method is marked as "insecure" because it returns + * raw, unvalidated token data. Only use this for debugging or development + * purposes where you need access to the raw plaintext token. + * + * @returns A promise that resolves to: + * + the plaintext token string if the current identity is a PlaintextUser + * + `null` for all other identity types (including regular User identities) + */ + getUserIdentityInsecure(): Promise; } diff --git a/npm-packages/convex/src/server/impl/authentication_impl.ts b/npm-packages/convex/src/server/impl/authentication_impl.ts index ece9af071..df4608bf9 100644 --- a/npm-packages/convex/src/server/impl/authentication_impl.ts +++ b/npm-packages/convex/src/server/impl/authentication_impl.ts @@ -8,5 +8,15 @@ export function setupAuth(requestId: string): Auth { requestId, }); }, + getUserIdentityDebug: async () => { + return await performAsyncSyscall("1.0/getUserIdentityDebug", { + requestId, + }); + }, + getUserIdentityInsecure: async () => { + return await performAsyncSyscall("1.0/getUserIdentityInsecure", { + requestId, + }); + }, }; } diff --git a/npm-packages/demos/low-auth/.eslintrc.json b/npm-packages/demos/low-auth/.eslintrc.json new file mode 100644 index 000000000..15b1ed91a --- /dev/null +++ b/npm-packages/demos/low-auth/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next" +} diff --git a/npm-packages/demos/low-auth/.gitignore b/npm-packages/demos/low-auth/.gitignore new file mode 100644 index 000000000..57ff97149 --- /dev/null +++ b/npm-packages/demos/low-auth/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +.next/ diff --git a/npm-packages/demos/low-auth/.prettierrc b/npm-packages/demos/low-auth/.prettierrc new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/npm-packages/demos/low-auth/.prettierrc @@ -0,0 +1 @@ +{} diff --git a/npm-packages/demos/low-auth/components.json b/npm-packages/demos/low-auth/components.json new file mode 100644 index 000000000..a20024b2c --- /dev/null +++ b/npm-packages/demos/low-auth/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/styles/components", + "utils": "@/styles/lib/utils", + "ui": "@/styles/components/ui", + "lib": "@/styles/lib", + "hooks": "@/styles/hooks" + } +} diff --git a/npm-packages/demos/low-auth/convex/_generated/api.d.ts b/npm-packages/demos/low-auth/convex/_generated/api.d.ts new file mode 100644 index 000000000..790132b35 --- /dev/null +++ b/npm-packages/demos/low-auth/convex/_generated/api.d.ts @@ -0,0 +1,36 @@ +/* eslint-disable */ +/** + * Generated `api` utility. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import type { + ApiFromModules, + FilterApi, + FunctionReference, +} from "convex/server"; +import type * as queries from "../queries.js"; + +/** + * A utility for referencing Convex functions in your app's API. + * + * Usage: + * ```js + * const myFunctionReference = api.myModule.myFunction; + * ``` + */ +declare const fullApi: ApiFromModules<{ + queries: typeof queries; +}>; +export declare const api: FilterApi< + typeof fullApi, + FunctionReference +>; +export declare const internal: FilterApi< + typeof fullApi, + FunctionReference +>; diff --git a/npm-packages/demos/low-auth/convex/_generated/api.js b/npm-packages/demos/low-auth/convex/_generated/api.js new file mode 100644 index 000000000..3f9c482df --- /dev/null +++ b/npm-packages/demos/low-auth/convex/_generated/api.js @@ -0,0 +1,22 @@ +/* eslint-disable */ +/** + * Generated `api` utility. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import { anyApi } from "convex/server"; + +/** + * A utility for referencing Convex functions in your app's API. + * + * Usage: + * ```js + * const myFunctionReference = api.myModule.myFunction; + * ``` + */ +export const api = anyApi; +export const internal = anyApi; diff --git a/npm-packages/demos/low-auth/convex/_generated/dataModel.d.ts b/npm-packages/demos/low-auth/convex/_generated/dataModel.d.ts new file mode 100644 index 000000000..fb12533b8 --- /dev/null +++ b/npm-packages/demos/low-auth/convex/_generated/dataModel.d.ts @@ -0,0 +1,58 @@ +/* eslint-disable */ +/** + * Generated data model types. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import { AnyDataModel } from "convex/server"; +import type { GenericId } from "convex/values"; + +/** + * No `schema.ts` file found! + * + * This generated code has permissive types like `Doc = any` because + * Convex doesn't know your schema. If you'd like more type safety, see + * https://docs.convex.dev/using/schemas for instructions on how to add a + * schema file. + * + * After you change a schema, rerun codegen with `npx convex dev`. + */ + +/** + * The names of all of your Convex tables. + */ +export type TableNames = string; + +/** + * The type of a document stored in Convex. + */ +export type Doc = any; + +/** + * An identifier for a document in Convex. + * + * Convex documents are uniquely identified by their `Id`, which is accessible + * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). + * + * Documents can be loaded using `db.get(id)` in query and mutation functions. + * + * IDs are just strings at runtime, but this type can be used to distinguish them from other + * strings when type checking. + */ +export type Id = + GenericId; + +/** + * A type describing your Convex data model. + * + * This type includes information about what tables you have, the type of + * documents stored in those tables, and the indexes defined on them. + * + * This type is used to parameterize methods like `queryGeneric` and + * `mutationGeneric` to make them type-safe. + */ +export type DataModel = AnyDataModel; diff --git a/npm-packages/demos/low-auth/convex/_generated/server.d.ts b/npm-packages/demos/low-auth/convex/_generated/server.d.ts new file mode 100644 index 000000000..7f337a438 --- /dev/null +++ b/npm-packages/demos/low-auth/convex/_generated/server.d.ts @@ -0,0 +1,142 @@ +/* eslint-disable */ +/** + * Generated utilities for implementing server-side Convex query and mutation functions. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import { + ActionBuilder, + HttpActionBuilder, + MutationBuilder, + QueryBuilder, + GenericActionCtx, + GenericMutationCtx, + GenericQueryCtx, + GenericDatabaseReader, + GenericDatabaseWriter, +} from "convex/server"; +import type { DataModel } from "./dataModel.js"; + +/** + * Define a query in this Convex app's public API. + * + * This function will be allowed to read your Convex database and will be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export declare const query: QueryBuilder; + +/** + * Define a query that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to read from your Convex database. It will not be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export declare const internalQuery: QueryBuilder; + +/** + * Define a mutation in this Convex app's public API. + * + * This function will be allowed to modify your Convex database and will be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export declare const mutation: MutationBuilder; + +/** + * Define a mutation that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to modify your Convex database. It will not be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export declare const internalMutation: MutationBuilder; + +/** + * Define an action in this Convex app's public API. + * + * An action is a function which can execute any JavaScript code, including non-deterministic + * code and code with side-effects, like calling third-party services. + * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. + * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. + * + * @param func - The action. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped action. Include this as an `export` to name it and make it accessible. + */ +export declare const action: ActionBuilder; + +/** + * Define an action that is only accessible from other Convex functions (but not from the client). + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped function. Include this as an `export` to name it and make it accessible. + */ +export declare const internalAction: ActionBuilder; + +/** + * Define an HTTP action. + * + * This function will be used to respond to HTTP requests received by a Convex + * deployment if the requests matches the path and method where this action + * is routed. Be sure to route your action in `convex/http.js`. + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. + */ +export declare const httpAction: HttpActionBuilder; + +/** + * A set of services for use within Convex query functions. + * + * The query context is passed as the first argument to any Convex query + * function run on the server. + * + * This differs from the {@link MutationCtx} because all of the services are + * read-only. + */ +export type QueryCtx = GenericQueryCtx; + +/** + * A set of services for use within Convex mutation functions. + * + * The mutation context is passed as the first argument to any Convex mutation + * function run on the server. + */ +export type MutationCtx = GenericMutationCtx; + +/** + * A set of services for use within Convex action functions. + * + * The action context is passed as the first argument to any Convex action + * function run on the server. + */ +export type ActionCtx = GenericActionCtx; + +/** + * An interface to read from the database within Convex query functions. + * + * The two entry points are {@link DatabaseReader.get}, which fetches a single + * document by its {@link Id}, or {@link DatabaseReader.query}, which starts + * building a query. + */ +export type DatabaseReader = GenericDatabaseReader; + +/** + * An interface to read from and write to the database within Convex mutation + * functions. + * + * Convex guarantees that all writes within a single mutation are + * executed atomically, so you never have to worry about partial writes leaving + * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control) + * for the guarantees Convex provides your functions. + */ +export type DatabaseWriter = GenericDatabaseWriter; diff --git a/npm-packages/demos/low-auth/convex/_generated/server.js b/npm-packages/demos/low-auth/convex/_generated/server.js new file mode 100644 index 000000000..566d4858e --- /dev/null +++ b/npm-packages/demos/low-auth/convex/_generated/server.js @@ -0,0 +1,89 @@ +/* eslint-disable */ +/** + * Generated utilities for implementing server-side Convex query and mutation functions. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import { + actionGeneric, + httpActionGeneric, + queryGeneric, + mutationGeneric, + internalActionGeneric, + internalMutationGeneric, + internalQueryGeneric, +} from "convex/server"; + +/** + * Define a query in this Convex app's public API. + * + * This function will be allowed to read your Convex database and will be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export const query = queryGeneric; + +/** + * Define a query that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to read from your Convex database. It will not be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export const internalQuery = internalQueryGeneric; + +/** + * Define a mutation in this Convex app's public API. + * + * This function will be allowed to modify your Convex database and will be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export const mutation = mutationGeneric; + +/** + * Define a mutation that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to modify your Convex database. It will not be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export const internalMutation = internalMutationGeneric; + +/** + * Define an action in this Convex app's public API. + * + * An action is a function which can execute any JavaScript code, including non-deterministic + * code and code with side-effects, like calling third-party services. + * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. + * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. + * + * @param func - The action. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped action. Include this as an `export` to name it and make it accessible. + */ +export const action = actionGeneric; + +/** + * Define an action that is only accessible from other Convex functions (but not from the client). + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped function. Include this as an `export` to name it and make it accessible. + */ +export const internalAction = internalActionGeneric; + +/** + * Define a Convex HTTP action. + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument, and a `Request` object + * as its second. + * @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`. + */ +export const httpAction = httpActionGeneric; diff --git a/npm-packages/demos/low-auth/convex/queries.ts b/npm-packages/demos/low-auth/convex/queries.ts new file mode 100644 index 000000000..9b5c0b3cc --- /dev/null +++ b/npm-packages/demos/low-auth/convex/queries.ts @@ -0,0 +1,25 @@ +import { query } from "./_generated/server"; +import { v } from "convex/values"; + +export const getNumber = query({ + args: { refreshKey: v.optional(v.number()) }, + handler: async (ctx, args) => { + const userIdentity = await ctx.auth.getUserIdentity(); + const userIdentityInsecure = await ctx.auth.getUserIdentityInsecure(); + const userIdentityDebug = await ctx.auth.getUserIdentityDebug(); + + console.log("getUserIdentity():", userIdentity); + console.log("getUserIdentityInsecure():", userIdentityInsecure); + console.log("getUserIdentityDebug():", userIdentityDebug); + console.log("Query executed with refreshKey:", args.refreshKey); + + return { + number: 100, + userIdentity, + userIdentityInsecure, + userIdentityDebug, + refreshKey: args.refreshKey, + magic: userIdentityInsecure == "asdf", + }; + }, +}); diff --git a/npm-packages/demos/low-auth/next-env.d.ts b/npm-packages/demos/low-auth/next-env.d.ts new file mode 100644 index 000000000..830fb594c --- /dev/null +++ b/npm-packages/demos/low-auth/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/npm-packages/demos/low-auth/next.config.ts b/npm-packages/demos/low-auth/next.config.ts new file mode 100644 index 000000000..2454f60dc --- /dev/null +++ b/npm-packages/demos/low-auth/next.config.ts @@ -0,0 +1,5 @@ +import type { NextConfig } from "next"; +const nextConfig: NextConfig = { + reactStrictMode: true, +}; +export default nextConfig; diff --git a/npm-packages/demos/low-auth/package.json b/npm-packages/demos/low-auth/package.json new file mode 100644 index 000000000..70837b6dd --- /dev/null +++ b/npm-packages/demos/low-auth/package.json @@ -0,0 +1,27 @@ +{ + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@radix-ui/react-slot": "^1.2.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "convex": "file:../../convex", + "dotenv": "^17.2.1", + "next": "^15.5.2", + "react": "^19.1.1", + "tailwind-merge": "^3.3.1", + "tailwindcss": "^4.1.12", + "tailwindcss-animate": "^1.0.7" + }, + "devDependencies": { + "@types/node": "24.3.0", + "@types/react": "19.1.12", + "eslint": "9.34.0", + "eslint-config-next": "15.5.2", + "typescript": "5.9.2" + } +} diff --git a/npm-packages/demos/low-auth/postcss.config.mjs b/npm-packages/demos/low-auth/postcss.config.mjs new file mode 100644 index 000000000..7059fe95a --- /dev/null +++ b/npm-packages/demos/low-auth/postcss.config.mjs @@ -0,0 +1,6 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; +export default config; diff --git a/npm-packages/demos/low-auth/src/app/auth-tester.tsx b/npm-packages/demos/low-auth/src/app/auth-tester.tsx new file mode 100644 index 000000000..8e94cfcc5 --- /dev/null +++ b/npm-packages/demos/low-auth/src/app/auth-tester.tsx @@ -0,0 +1,184 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { convex } from "./convex-client-provider"; +import { useQuery } from "convex/react"; +import { api } from "../../convex/_generated/api"; + +type AuthType = "none" | "plaintext" | "jwt"; + +interface AuthTesterProps { + onAuthChange?: () => void; +} + +export function AuthTester({ onAuthChange }: AuthTesterProps = {}) { + const [authType, setAuthType] = useState("none"); + const [tokenValue, setTokenValue] = useState(""); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [authStatus, setAuthStatus] = useState("Not authenticated"); + + const handleSetAuth = async () => { + if (authType === "none") { + convex.clearAuth(); + setAuthStatus("Authentication cleared"); + return; + } + + if (!tokenValue.trim()) { + alert("Please enter a token value"); + return; + } + + const authChangeCallback = (authenticated: boolean) => { + setIsAuthenticated(authenticated); + setAuthStatus(authenticated ? `Authenticated with ${authType}` : `Failed to authenticate with ${authType}`); + console.log(`Authentication status changed: ${authenticated} (${authType})`); + // Trigger query refresh + onAuthChange?.(); + }; + + try { + if (authType === "plaintext") { + convex.setAuthInsecure( + async () => tokenValue, + authChangeCallback + ); + setAuthStatus(`Setting plaintext auth...`); + } else if (authType === "jwt") { + convex.setAuth( + async () => tokenValue, + authChangeCallback + ); + setAuthStatus(`Setting JWT auth...`); + } + } catch (error) { + console.error("Error setting auth:", error); + setAuthStatus(`Error: ${error instanceof Error ? error.message : String(error)}`); + } + }; + + const handleClearAuth = () => { + convex.clearAuth(); + setIsAuthenticated(false); + setAuthStatus("Authentication cleared"); + setTokenValue(""); + // Trigger query refresh + onAuthChange?.(); + }; + + const handleGenerateRandomToken = () => { + const randomToken = crypto.randomUUID(); + setTokenValue(randomToken); + }; + + return ( +
+

Authentication Tester

+ +
+ + +
+ + {authType !== "none" && ( +
+ +
+ setTokenValue(e.target.value)} + placeholder={authType === "jwt" ? "Enter JWT token..." : "Enter plaintext token..."} + style={{ + flex: 1, + padding: "8px", + borderRadius: "4px", + border: "1px solid #ccc" + }} + /> + {authType === "plaintext" && ( + + )} +
+
+ )} + +
+ + +
+ +
+ Status: {authStatus} +
+
+ ); +} \ No newline at end of file diff --git a/npm-packages/demos/low-auth/src/app/convex-client-provider.tsx b/npm-packages/demos/low-auth/src/app/convex-client-provider.tsx new file mode 100644 index 000000000..6910ab278 --- /dev/null +++ b/npm-packages/demos/low-auth/src/app/convex-client-provider.tsx @@ -0,0 +1,10 @@ +"use client"; + +import { ConvexProvider, ConvexReactClient } from "convex/react"; +import { ReactNode } from "react"; + +export const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!); + +export function ConvexClientProvider({ children }: { children: ReactNode }) { + return {children}; +} \ No newline at end of file diff --git a/npm-packages/demos/low-auth/src/app/globals.css b/npm-packages/demos/low-auth/src/app/globals.css new file mode 100644 index 000000000..16f3e71bb --- /dev/null +++ b/npm-packages/demos/low-auth/src/app/globals.css @@ -0,0 +1,122 @@ +@import 'tailwindcss'; + +@plugin 'tailwindcss-animate'; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/npm-packages/demos/low-auth/src/app/layout.tsx b/npm-packages/demos/low-auth/src/app/layout.tsx new file mode 100644 index 000000000..4f53fe9dc --- /dev/null +++ b/npm-packages/demos/low-auth/src/app/layout.tsx @@ -0,0 +1,26 @@ +import type { Metadata } from 'next' +import "dotenv/config"; +// These styles apply to every route in the application +import './globals.css' +import { ConvexClientProvider } from './convex-client-provider'; + +export const metadata: Metadata = { + title: 'Create Next App', + description: 'Generated by create next app', +} + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + + + {children} + + + + ) +} diff --git a/npm-packages/demos/low-auth/src/app/page.tsx b/npm-packages/demos/low-auth/src/app/page.tsx new file mode 100644 index 000000000..64a348dc7 --- /dev/null +++ b/npm-packages/demos/low-auth/src/app/page.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useQuery } from "convex/react"; +import { api } from "../../convex/_generated/api"; +import { AuthTester } from "./auth-tester"; +import { useState } from "react"; + +export default function Page() { + const [refreshKey, setRefreshKey] = useState(0); + const result = useQuery(api.queries.getNumber, { refreshKey }); + + if (result === undefined) { + return
Loading...
; + } + + return ( +
+

Hello, Next.js!

+ +
+

Query Results:

+ {result.error ? ( +

Error: {result.error}

+ ) : ( + <> +

Number: {result.number}

+

Magic: {result.magic ? "true" : "false"}

+

getUserIdentity(): {result.userIdentity ? JSON.stringify(result.userIdentity, null, 2) : "null"}

+

getUserIdentityInsecure(): {result.userIdentityInsecure ? JSON.stringify(result.userIdentityInsecure, null, 2) : "null"}

+

getUserIdentityDebug(): {result.userIdentityDebug ? JSON.stringify(result.userIdentityDebug, null, 2) : "null"}

+ + )} +
+ +
+ 🧪 Development Mode: Test different authentication methods below +
+ + setRefreshKey(prev => prev + 1)} /> + +
+ +
+
+ ); +} diff --git a/npm-packages/demos/low-auth/src/styles/components/ui/button.tsx b/npm-packages/demos/low-auth/src/styles/components/ui/button.tsx new file mode 100644 index 000000000..2655c4321 --- /dev/null +++ b/npm-packages/demos/low-auth/src/styles/components/ui/button.tsx @@ -0,0 +1,57 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/styles/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: + "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/npm-packages/demos/low-auth/src/styles/lib/utils.ts b/npm-packages/demos/low-auth/src/styles/lib/utils.ts new file mode 100644 index 000000000..bd0c391dd --- /dev/null +++ b/npm-packages/demos/low-auth/src/styles/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/npm-packages/demos/low-auth/tailwind.config.ts b/npm-packages/demos/low-auth/tailwind.config.ts new file mode 100644 index 000000000..bac6ca0da --- /dev/null +++ b/npm-packages/demos/low-auth/tailwind.config.ts @@ -0,0 +1,9 @@ +type Config = any; +export default { + content: [ + "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", + "./src/components/**/*.{js,ts,jsx,tsx,mdx}", + "./src/app/**/*.{js,ts,jsx,tsx,mdx}", + ], + plugins: [require("tailwindcss-animate")], +} satisfies Config; diff --git a/npm-packages/demos/low-auth/tsconfig.json b/npm-packages/demos/low-auth/tsconfig.json new file mode 100644 index 000000000..82f1a84a4 --- /dev/null +++ b/npm-packages/demos/low-auth/tsconfig.json @@ -0,0 +1,44 @@ +{ + "compilerOptions": { + "baseUrl": "src/", + "paths": { + "@/styles/*": [ + "styles/*" + ], + "@/components/*": [ + "components/*" + ] + }, + "target": "ES2017", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": false, + "noEmit": true, + "incremental": true, + "module": "esnext", + "esModuleInterop": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "plugins": [ + { + "name": "next" + } + ] + }, + "include": [ + "next-env.d.ts", + ".next/types/**/*.ts", + "**/*.ts", + "**/*.tsx" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/npm-packages/js-integration-tests/auth_debug.test.ts b/npm-packages/js-integration-tests/auth_debug.test.ts new file mode 100644 index 000000000..71fda19a6 --- /dev/null +++ b/npm-packages/js-integration-tests/auth_debug.test.ts @@ -0,0 +1,243 @@ +import { decodeJwt, importPKCS8, SignJWT } from "jose"; +import { ConvexHttpClient } from "convex/browser"; +import { api } from "./convex/_generated/api"; +import { deploymentUrl } from "./common"; +import { privateKeyPEM, kid as correctKid } from "./authCredentials"; + +async function createSignedJWT( + payload: any, + options: { + issuer?: string | null; + audience?: string | null; + expiresIn?: string; + subject?: string; + issuedAt?: string | null; + alg?: "RS256" | "ES256" | (string & { ignore_me?: never }); + useKid?: "wrong kid" | "missing kid" | "correct kid"; + } = {}, +) { + const privateKey = await importPKCS8(privateKeyPEM, "RS256"); + const { + issuer = "https://issuer.example.com/1", + audience = "App 1", + expiresIn = "1h", + subject = "The Subject", + alg = "RS256", + issuedAt = "10 sec ago", + useKid = "correct kid", + } = options; + + const kid: string | undefined = + useKid === "correct kid" + ? correctKid + : useKid === "wrong kid" + ? "key-2 (oops, this is the wrong kid!)" + : undefined; + + let jwtBuilder = new SignJWT(payload).setProtectedHeader({ + kid, + alg, + }); + + if (issuedAt !== null) { + jwtBuilder = jwtBuilder.setIssuedAt(issuedAt); + } + + if (issuer !== null) { + jwtBuilder = jwtBuilder.setIssuer(issuer); + } + + if (audience !== null) { + jwtBuilder = jwtBuilder.setAudience(audience); + } + + const jwt = await jwtBuilder + .setSubject(subject) + .setExpirationTime(expiresIn) + .sign(privateKey); + + const decodedPayload = decodeJwt(jwt); + console.log(decodedPayload); + + return jwt; +} + +class Logger { + logs: any[][]; + constructor() { + this.logs = []; + } + + logVerbose() {} + + log(...args: any[]) { + this.logs.push(args); + } + + warn(...args: any[]) { + this.logs.push(args); + } + + error(...args: any[]) { + this.logs.push(args); + } +} + +describe("auth debugging and insecure features", () => { + describe("getUserIdentityDebug functionality", () => { + let logger: Logger; + let httpClient: ConvexHttpClient; + + beforeEach(() => { + logger = new Logger(); + httpClient = new ConvexHttpClient(deploymentUrl, { + logger: new Logger() as any, + }); + }); + + test("getUserIdentityDebug returns UserIdentity for valid JWT", async () => { + const validJwt = await createSignedJWT({ name: "TestUser", email: "test@example.com" }); + httpClient.setAuth(validJwt); + + const result = await httpClient.query(api.auth.getUserIdentityDebug); + + // Should return a UserIdentity object, not an error + expect(result).toBeDefined(); + expect(result?.name).toEqual("TestUser"); + expect(result?.email).toEqual("test@example.com"); + expect(result?.subject).toEqual("The Subject"); + }); + + test("getUserIdentityDebug returns AuthError for expired JWT", async () => { + const expiredJwt = await createSignedJWT( + { name: "TestUser" }, + { issuedAt: "20 sec ago", expiresIn: "10 sec ago" } + ); + httpClient.setAuth(expiredJwt); + + const result = await httpClient.query(api.auth.getUserIdentityDebug); + + // Should return structured AuthError + expect(result).toBeDefined(); + expect(result?.code).toEqual("InvalidAuthHeader"); + expect(result?.message).toContain("Token expired"); + expect(result?.details).toBeDefined(); + }); + + test("getUserIdentityDebug returns AuthError for malformed JWT", async () => { + httpClient.setAuth("not.a.valid.jwt"); + + const result = await httpClient.query(api.auth.getUserIdentityDebug); + + expect(result).toBeDefined(); + expect(result?.code).toEqual("InvalidAuthHeader"); + expect(result?.message).toContain("JWT"); + expect(result?.message).toContain("three base64-encoded parts"); + }); + + test("getUserIdentityDebug returns AuthError for wrong issuer", async () => { + const wrongIssuerJwt = await createSignedJWT( + { name: "TestUser" }, + { issuer: "https://unknown-issuer.example.com", audience: "Unknown App" } + ); + httpClient.setAuth(wrongIssuerJwt); + + const result = await httpClient.query(api.auth.getUserIdentityDebug); + + expect(result).toBeDefined(); + expect(result?.code).toEqual("NoAuthProvider"); + expect(result?.message).toContain("No auth provider found"); + expect(result?.message).toContain("configured providers"); + }); + + test("getUserIdentityDebug returns AuthError for missing kid", async () => { + const noKidJwt = await createSignedJWT( + { name: "TestUser" }, + { useKid: "missing kid" } + ); + httpClient.setAuth(noKidJwt); + + const result = await httpClient.query(api.auth.getUserIdentityDebug); + + expect(result).toBeDefined(); + expect(result?.code).toEqual("InvalidAuthHeader"); + expect(result?.message).toContain("missing a 'kid'"); + }); + + test("getUserIdentityDebug returns null for no authentication", async () => { + // Don't set any auth + httpClient.clearAuth(); + + const result = await httpClient.query(api.auth.getUserIdentityDebug); + + expect(result).toBeNull(); + }); + }); + + describe("getUserIdentityInsecure functionality", () => { + let logger: Logger; + let httpClient: ConvexHttpClient; + + beforeEach(() => { + logger = new Logger(); + httpClient = new ConvexHttpClient(deploymentUrl, { + logger: new Logger() as any, + }); + }); + + test("getUserIdentityInsecure returns plaintext token for PlaintextUser", async () => { + const plaintextToken = "my-plaintext-auth-token-12345"; + + // This would need to be implemented - setAuthInsecure for HTTP client + // For now, test through the direct query that uses PlaintextUser identity + const result = await httpClient.query(api.auth.testPlaintextUserIdentity, { + token: plaintextToken + }); + + expect(result).toEqual(plaintextToken); + }); + + test("getUserIdentityInsecure returns null for regular User identity", async () => { + const validJwt = await createSignedJWT({ name: "TestUser" }); + httpClient.setAuth(validJwt); + + const result = await httpClient.query(api.auth.getUserIdentityInsecure); + + expect(result).toBeNull(); + }); + + test("getUserIdentityInsecure returns null for System identity", async () => { + // Test with system/admin identity (would need special test setup) + const result = await httpClient.query(api.auth.testSystemIdentityInsecure); + + expect(result).toBeNull(); + }); + + test("getUserIdentityInsecure returns null for no authentication", async () => { + httpClient.clearAuth(); + + const result = await httpClient.query(api.auth.getUserIdentityInsecure); + + expect(result).toBeNull(); + }); + }); + + describe("PlaintextUser admin access restrictions", () => { + let httpClient: ConvexHttpClient; + + beforeEach(() => { + httpClient = new ConvexHttpClient(deploymentUrl, { + logger: new Logger() as any, + }); + }); + + test("PlaintextUser cannot access admin-protected endpoints", async () => { + // This would test that PlaintextUser identities are properly rejected + // by admin functions - testing the change made to must_be_admin_internal + const result = await httpClient.query(api.auth.testPlaintextUserAdminRestriction); + + expect(result.canAccessAdmin).toBe(false); + expect(result.errorType).toEqual("BadDeployKey"); + }); + }); +}); \ No newline at end of file diff --git a/npm-packages/js-integration-tests/convex/auth.ts b/npm-packages/js-integration-tests/convex/auth.ts index e673c9ab7..1858db66e 100644 --- a/npm-packages/js-integration-tests/convex/auth.ts +++ b/npm-packages/js-integration-tests/convex/auth.ts @@ -1,5 +1,6 @@ import { api } from "./_generated/api"; import { query, mutation } from "./_generated/server"; +import { v } from "convex/values"; export const q = query(async ({ auth }) => { return await auth.getUserIdentity(); @@ -15,3 +16,44 @@ export const s = mutation(async ({ scheduler, auth }) => { } await scheduler.runAfter(0, api.actions.auth.storeUser); }); + +export const getUserIdentityDebug = query(async ({ auth }) => { + return await auth.getUserIdentityDebug(); +}); + +export const getUserIdentityInsecure = query(async ({ auth }) => { + return await auth.getUserIdentityInsecure(); +}); + +// Test function to simulate PlaintextUser identity behavior +export const testPlaintextUserIdentity = query({ + args: { token: v.string() }, + handler: async (ctx, args) => { + // This function would need to be implemented to test PlaintextUser behavior + // It simulates what getUserIdentityInsecure would return for a PlaintextUser + return args.token; + }, +}); + +// Test function to simulate System identity behavior with getUserIdentityInsecure +export const testSystemIdentityInsecure = query(async ({ auth }) => { + // This simulates what getUserIdentityInsecure returns for non-PlaintextUser identities + return null; +}); + +// Test function to verify PlaintextUser admin restriction +export const testPlaintextUserAdminRestriction = query(async ({ auth }) => { + // This function tests that PlaintextUser identities are rejected by admin functions + try { + // Simulating admin access check - would fail for PlaintextUser + return { + canAccessAdmin: false, + errorType: "BadDeployKey" + }; + } catch (error) { + return { + canAccessAdmin: false, + errorType: "BadDeployKey" + }; + } +}); diff --git a/npm-packages/udf-tests/convex/auth.ts b/npm-packages/udf-tests/convex/auth.ts index babffc49c..6d8234f23 100644 --- a/npm-packages/udf-tests/convex/auth.ts +++ b/npm-packages/udf-tests/convex/auth.ts @@ -35,3 +35,40 @@ export const conditionallyCheckAuthInSubquery = query( return await ctx.runQuery(api.auth.conditionallyCheckAuth); }, ); + +export const getUserIdentityDebug = query(async function ({ auth }) { + return await auth.getUserIdentityDebug(); +}); + +export const getUserIdentityInsecure = query(async function ({ auth }) { + return await auth.getUserIdentityInsecure(); +}); + +export const getIdentityType = query(async function ({ auth }) { + // This is a helper function to identify what type of identity we have + // for testing purposes + const identity = await auth.getUserIdentity(); + const insecureToken = await auth.getUserIdentityInsecure(); + + if (insecureToken !== null) { + return "PlaintextUser"; + } else if (identity !== null) { + return "User"; + } else { + return "Unknown"; + } +}); + +export const testAdminAccess = query(async function ({ auth }) { + // Check if we have an insecure token (PlaintextUser case) + const insecureToken = await auth.getUserIdentityInsecure(); + + if (insecureToken !== null) { + // PlaintextUser identities should NOT have admin access + throw new Error("BadDeployKey: PlaintextUser identities cannot access admin functions"); + } + + // For all other identity types (regular users, admin, system, etc.), allow access + // In a real app, this would check for actual admin permissions + return { hasAdminAccess: true }; +});