Skip to main content

surrealdb_core/iam/
token.rs

1use std::collections::HashMap;
2use std::fmt;
3use std::sync::LazyLock;
4
5use anyhow::{Result, bail};
6use jsonwebtoken::{Algorithm, Header};
7use serde::{Deserialize, Serialize};
8use surrealdb_types::SurrealValue;
9
10use crate::dbs::Session;
11use crate::err::Error;
12use crate::kvs::Datastore;
13use crate::sql::expression::convert_public_value_to_internal;
14use crate::val::{Object, Value, convert_object_to_public_map};
15use crate::{iam, syn};
16pub static HEADER: LazyLock<Header> = LazyLock::new(|| Header::new(Algorithm::HS512));
17
18/// Decodes JWT claims from an access token without cryptographic verification.
19///
20/// SAFETY: This is used exclusively during token refresh and revocation to extract
21/// routing information (namespace, database, access method) from an expired access
22/// token. The refresh token itself provides the real authentication and is fully
23/// validated during the subsequent signin process.
24fn decode_access_token_claims(token: &str) -> Result<jsonwebtoken::TokenData<Claims>> {
25	Ok(jsonwebtoken::dangerous::insecure_decode::<Claims>(token)?)
26}
27
28/// A token that can be either an access token alone or an access token with a refresh token.
29///
30/// This enum supports two authentication scenarios:
31/// - **Access-only**: A single access token for basic authentication
32/// - **With refresh**: An access token paired with a refresh token for enhanced security
33///
34/// The enum uses untagged serialization, meaning it will serialize as either:
35/// - A string (for access-only tokens)
36/// - An object with `access` and `refresh` fields (for tokens with refresh)
37///
38/// # Refresh Token Flow
39///
40/// When using the `WithRefresh` variant, the token can be refreshed to obtain a new access token
41/// without requiring the user to re-authenticate. The refresh process:
42///
43/// 1. Extracts the authentication scope (namespace, database, access method) from the expired
44///    access token's JWT claims
45/// 2. Uses the refresh token to authenticate and validate the request
46/// 3. Revokes the old refresh token (refresh tokens are single-use)
47/// 4. Issues a new access token and refresh token pair
48/// 5. Restores the session to the original authentication scope
49///
50/// This ensures that refresh maintains the original authentication boundaries and prevents
51/// scope confusion or escalation.
52///
53/// # Examples
54///
55/// ```rust
56/// use surrealdb_core::iam::token::Token;
57///
58/// // Access-only token
59/// let access_token = Token::Access("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...".to_string());
60///
61/// // Token with refresh capability
62/// let token_with_refresh = Token::WithRefresh {
63///     access: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...".to_string(),
64///     refresh: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...".to_string(),
65/// };
66/// ```
67#[derive(Clone, Eq, PartialEq, PartialOrd, SurrealValue, Hash)]
68#[surreal(crate = "surrealdb_types")]
69#[surreal(untagged)]
70#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
71pub enum Token {
72	/// An access token without a refresh token.
73	///
74	/// This variant represents the traditional authentication model where
75	/// only a single access token is provided.
76	Access(String),
77	/// An access token paired with a refresh token.
78	///
79	/// This variant enables the refresh token flow, allowing clients to
80	/// obtain new access tokens without re-authenticating when the access
81	/// token expires.
82	WithRefresh {
83		/// The access token used for API authentication
84		access: String,
85		/// The refresh token used to obtain new access tokens
86		refresh: String,
87	},
88}
89
90impl Token {
91	/// Refreshes an access token using a refresh token.
92	///
93	/// This method exchanges an expired (or soon-to-expire) access token for a new one
94	/// using the provided refresh token. The refresh process follows OAuth2/JWT best practices
95	/// by maintaining the original authentication scope from the access token claims.
96	///
97	/// # Authentication Scope vs Working Context
98	///
99	/// It's important to understand the distinction between authentication scope and working
100	/// context:
101	///
102	/// - **Authentication Scope** (from token claims): The namespace, database, and access method
103	///   that were used during the original signin. This represents *what you're authenticated as*.
104	///
105	/// - **Working Context** (from session fields): The current namespace and database set by the
106	///   `USE` command. This represents *where you're currently working*.
107	///
108	/// During refresh, the authentication scope from the expired access token is used to create
109	/// the new token, and the session is restored to match this original scope. This means:
110	///
111	/// 1. If you signin to `ns1/db1`, then call `USE ns2 db2`, then refresh:
112	///    - The session will be restored to `ns1/db1` (original authentication scope)
113	///    - You can call `USE ns2 db2` again after refresh if needed
114	///
115	/// 2. The refresh token is validated against the namespace/database from the original signin,
116	///    not the current session working context.
117	///
118	/// This behavior is intentional and follows security best practices:
119	/// - Prevents scope confusion or escalation
120	/// - Maintains predictable authentication boundaries
121	/// - Aligns with OAuth2/OIDC refresh token standards
122	///
123	/// # Arguments
124	///
125	/// * `kvs` - The datastore to validate the refresh token against
126	/// * `session` - The session to update with the new authentication state
127	///
128	/// # Returns
129	///
130	/// Returns a new `Token` with fresh access and refresh tokens on success.
131	///
132	/// # Errors
133	///
134	/// Returns an error if:
135	/// - The token is an `Access` variant without a refresh token
136	/// - The refresh token is invalid, expired, or revoked
137	/// - The access token cannot be decoded
138	/// - The signin process fails
139	///
140	/// # Example
141	///
142	/// ```ignore
143	/// // Signin and get tokens
144	/// let token = iam::signin::signin(kvs, session, credentials).await?;
145	///
146	/// // Later, when the access token expires...
147	/// let new_token = token.refresh(kvs, session).await?;
148	/// ```
149	pub async fn refresh(self, kvs: &Datastore, session: &mut Session) -> Result<Self> {
150		match self {
151			Token::Access(_) => bail!(Error::InvalidFunctionArguments {
152				name: "refresh".into(),
153				message: "Token is an access token, cannot refresh".into(),
154			}),
155			Token::WithRefresh {
156				access,
157				refresh,
158			} => {
159				// Decode the expired access token to extract its claims.
160				// We don't verify the signature or expiration here because we're only
161				// extracting the authentication scope (NS, DB, AC, ID, etc.) to pass
162				// to the signin function. The refresh token itself will be validated
163				// during the signin process.
164				let token_data = decode_access_token_claims(&access)?;
165				let claims = token_data.claims.into_claims_object();
166				// Convert token claims to signin variables. These claims contain the
167				// original authentication scope (namespace, database, access method)
168				// that will be used to create the new tokens.
169				let mut vars = convert_object_to_public_map(claims)?;
170				// Add the refresh token to the variables. The signin function will
171				// use this to perform bearer authentication and validate the refresh token.
172				vars.insert("refresh".to_string(), refresh.into_value());
173				// Perform signin using the refresh token. This will:
174				// 1. Validate the refresh token against the stored grant
175				// 2. Revoke the old refresh token (single-use)
176				// 3. Create a new access token and refresh token
177				// 4. Update the session with the original authentication scope
178				iam::signin::signin(kvs, session, vars.into()).await
179			}
180		}
181	}
182
183	pub async fn revoke_refresh_token(self, kvs: &Datastore) -> Result<()> {
184		match self {
185			Token::Access(_) => bail!(Error::InvalidFunctionArguments {
186				name: "refresh".into(),
187				message: "Token is an access token, cannot revoke refresh token".into(),
188			}),
189			Token::WithRefresh {
190				access,
191				refresh,
192			} => {
193				let grant_id = iam::signin::validate_grant_bearer(&refresh)?;
194				let token_data = decode_access_token_claims(&access)?;
195				let ns = token_data.claims.ns.ok_or_else(|| Error::InvalidFunctionArguments {
196					name: "ns".into(),
197					message: "Token does not contain a namespace".into(),
198				})?;
199				let db = token_data.claims.db.ok_or_else(|| Error::InvalidFunctionArguments {
200					name: "db".into(),
201					message: "Token does not contain a database".into(),
202				})?;
203				let ac = token_data.claims.ac.ok_or_else(|| Error::InvalidFunctionArguments {
204					name: "ac".into(),
205					message: "Token does not contain an access name".into(),
206				})?;
207				iam::access::revoke_refresh_token_record(kvs, grant_id, ac, &ns, &db).await?;
208				Ok(())
209			}
210		}
211	}
212}
213
214impl fmt::Debug for Token {
215	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
216		match self {
217			Token::Access(_) => write!(f, "Token::Access(REDACTED)"),
218			Token::WithRefresh {
219				..
220			} => write!(f, "Token::WithRefresh {{ access: REDACTED, refresh: REDACTED }}"),
221		}
222	}
223}
224
225#[derive(Debug, Serialize, Deserialize, Clone)]
226#[serde(untagged)]
227pub enum Audience {
228	Single(String),
229	Multiple(Vec<String>),
230}
231
232#[derive(Debug, Default, Serialize, Deserialize, Clone)]
233pub struct Claims {
234	#[serde(skip_serializing_if = "Option::is_none")]
235	pub iat: Option<i64>,
236	#[serde(skip_serializing_if = "Option::is_none")]
237	pub nbf: Option<i64>,
238	#[serde(skip_serializing_if = "Option::is_none")]
239	pub exp: Option<i64>,
240	#[serde(skip_serializing_if = "Option::is_none")]
241	pub iss: Option<String>,
242	#[serde(skip_serializing_if = "Option::is_none")]
243	pub sub: Option<String>,
244	#[serde(skip_serializing_if = "Option::is_none")]
245	pub aud: Option<Audience>,
246	#[serde(skip_serializing_if = "Option::is_none")]
247	pub jti: Option<String>,
248	#[serde(alias = "ns")]
249	#[serde(alias = "NS")]
250	#[serde(rename = "NS")]
251	#[serde(alias = "https://surrealdb.com/ns")]
252	#[serde(alias = "https://surrealdb.com/namespace")]
253	#[serde(skip_serializing_if = "Option::is_none")]
254	pub ns: Option<String>,
255	#[serde(alias = "db")]
256	#[serde(alias = "DB")]
257	#[serde(rename = "DB")]
258	#[serde(alias = "https://surrealdb.com/db")]
259	#[serde(alias = "https://surrealdb.com/database")]
260	#[serde(skip_serializing_if = "Option::is_none")]
261	pub db: Option<String>,
262	#[serde(alias = "ac")]
263	#[serde(alias = "AC")]
264	#[serde(rename = "AC")]
265	#[serde(alias = "https://surrealdb.com/ac")]
266	#[serde(alias = "https://surrealdb.com/access")]
267	#[serde(skip_serializing_if = "Option::is_none")]
268	pub ac: Option<String>,
269	#[serde(alias = "id")]
270	#[serde(alias = "ID")]
271	#[serde(rename = "ID")]
272	#[serde(alias = "https://surrealdb.com/id")]
273	#[serde(alias = "https://surrealdb.com/record")]
274	#[serde(skip_serializing_if = "Option::is_none")]
275	pub id: Option<String>,
276	#[serde(alias = "rl")]
277	#[serde(alias = "RL")]
278	#[serde(rename = "RL")]
279	#[serde(alias = "https://surrealdb.com/rl")]
280	#[serde(alias = "https://surrealdb.com/roles")]
281	#[serde(skip_serializing_if = "Option::is_none")]
282	pub roles: Option<Vec<String>>,
283
284	#[serde(flatten)]
285	#[serde(skip_serializing_if = "Option::is_none")]
286	pub custom_claims: Option<HashMap<String, serde_json::Value>>,
287}
288
289impl Claims {
290	pub(crate) fn into_claims_object(self) -> Object {
291		// Set default value
292		let mut out = Object::default();
293		// Add iss field if set
294		if let Some(iss) = self.iss {
295			out.insert("iss", iss.into());
296		}
297		// Add sub field if set
298		if let Some(sub) = self.sub {
299			out.insert("sub", sub.into());
300		}
301		// Add aud field if set
302		if let Some(aud) = self.aud {
303			match aud {
304				Audience::Single(v) => out.insert("aud", Value::String(v.into())),
305				Audience::Multiple(v) => {
306					out.insert("aud", v.into_iter().map(Value::from).collect::<Vec<_>>().into())
307				}
308			};
309		}
310		// Add iat field if set
311		if let Some(iat) = self.iat {
312			out.insert("iat", iat.into());
313		}
314		// Add nbf field if set
315		if let Some(nbf) = self.nbf {
316			out.insert("nbf", nbf.into());
317		}
318		// Add exp field if set
319		if let Some(exp) = self.exp {
320			out.insert("exp", exp.into());
321		}
322		// Add jti field if set
323		if let Some(jti) = self.jti {
324			out.insert("jti", jti.into());
325		}
326		// Add NS field if set
327		if let Some(ns) = self.ns {
328			out.insert("NS", ns.into());
329		}
330		// Add DB field if set
331		if let Some(db) = self.db {
332			out.insert("DB", db.into());
333		}
334		// Add AC field if set
335		if let Some(ac) = self.ac {
336			out.insert("AC", ac.into());
337		}
338		// Add ID field if set
339		if let Some(id) = self.id {
340			out.insert("ID", id.into());
341		}
342		// Add RL field if set
343		if let Some(role) = self.roles {
344			out.insert("RL", role.into_iter().map(Value::from).collect::<Vec<_>>().into());
345		}
346		// Add custom claims if set
347		if let Some(custom_claims) = self.custom_claims {
348			for (claim, value) in custom_claims {
349				// Serialize the raw JSON string representing the claim value
350				let claim_json = match serde_json::to_string(&value) {
351					Ok(claim_json) => claim_json,
352					Err(err) => {
353						debug!("Failed to serialize token claim '{}': {}", claim, err);
354						continue;
355					}
356				};
357				// Parse that JSON string into the corresponding SurrealQL value
358				let claim_value = match syn::json(&claim_json) {
359					Ok(claim_value) => claim_value,
360					Err(err) => {
361						debug!("Failed to parse token claim '{}': {}", claim, err);
362						continue;
363					}
364				};
365				let claim_value = convert_public_value_to_internal(claim_value);
366				out.insert(claim.clone(), claim_value);
367			}
368		}
369		// Return value
370		out
371	}
372}