Skip to content

Commit 37aab18

Browse files
committed
feat: add personal access tokens
This commit adds API endpoints for managing personal access tokens. Users can create and revoke tokens, and tokens can be used to authenticate API requests. Tokens are created with a description, and an optional expiry date. Tokens can be revoked at any time. There is a UI for managing tokens in the account settings - users can create and revoke tokens from there. Tokens are displayed with their description, expiry date, the granted permissions, and the date they were created. An email is sent to users when a new token is created to notify them of the token's creation. The token creation UI contains a questionnaire flow to help users understand that what they are doing is potentially dangerous, and to guide users to use alternative authentication mechanisms such as OIDC or interactive flow where possible.
1 parent c9740ea commit 37aab18

28 files changed

+1299
-54
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TYPE token_type ADD VALUE 'personal';

api/src/api.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1945,6 +1945,31 @@ components:
19451945

19461946
Permission:
19471947
oneOf:
1948+
- type: object
1949+
properties:
1950+
permission:
1951+
type: string
1952+
description: The permission name.
1953+
enum: ["package/publish"]
1954+
scope:
1955+
$ref: "#/components/schemas/ScopeName"
1956+
required:
1957+
- permission
1958+
- scope
1959+
- type: object
1960+
properties:
1961+
permission:
1962+
type: string
1963+
description: The permission name.
1964+
enum: ["package/publish"]
1965+
scope:
1966+
$ref: "#/components/schemas/ScopeName"
1967+
package:
1968+
$ref: "#/components/schemas/PackageName"
1969+
required:
1970+
- permission
1971+
- scope
1972+
- package
19481973
- type: object
19491974
properties:
19501975
permission:

api/src/api/authorization.rs

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,7 @@ mod tests {
255255
use crate::api::ApiAuthorizationExchangeResponse;
256256
use crate::api::ApiCreateAuthorizationResponse;
257257
use crate::api::ApiFullUser;
258+
use crate::db::PackagePublishPermission;
258259
use crate::db::Permission;
259260
use crate::db::Permissions;
260261
use crate::util::test::ApiResultExt;
@@ -367,12 +368,14 @@ mod tests {
367368

368369
let (verifier, challenge) = new_verifier_and_challenge();
369370

370-
let permissions = Permissions(vec![Permission::VersionPublish {
371-
scope: t.scope.scope.clone(),
372-
package: "test".try_into().unwrap(),
373-
version: "1.0.0".try_into().unwrap(),
374-
tarball_hash: "sha256-1234567890".into(),
375-
}]);
371+
let permissions = Permissions(vec![Permission::PackagePublish(
372+
PackagePublishPermission::Version {
373+
scope: t.scope.scope.clone(),
374+
package: "test".try_into().unwrap(),
375+
version: "1.0.0".try_into().unwrap(),
376+
tarball_hash: "sha256-1234567890".into(),
377+
},
378+
)]);
376379

377380
let mut resp =
378381
create_authorization(&mut t, &challenge, Some(permissions)).await;

api/src/api/errors.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ errors!(
5252
status: NOT_FOUND,
5353
"The requested path was not found.",
5454
},
55+
TokenNotFound {
56+
status: NOT_FOUND,
57+
"The requested token was not found.",
58+
},
5559
InternalServerError {
5660
status: INTERNAL_SERVER_ERROR,
5761
"Internal Server Error",

api/src/api/self_user.rs

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,28 @@ use tracing::field;
99
use tracing::instrument;
1010
use tracing::Span;
1111

12+
use std::borrow::Cow;
13+
1214
use crate::db::Database;
15+
use crate::db::TokenType;
1316
use crate::db::UserPublic;
17+
use crate::emails::EmailArgs;
18+
use crate::emails::EmailSender;
1419
use crate::iam::ReqIamExt;
1520
use crate::util;
21+
use crate::util::decode_json;
1622
use crate::util::ApiResult;
1723
use crate::util::RequestIdExt;
24+
use crate::RegistryUrl;
1825

26+
use super::ApiCreateTokenRequest;
27+
use super::ApiCreatedToken;
1928
use super::ApiError;
2029
use super::ApiFullUser;
2130
use super::ApiScope;
2231
use super::ApiScopeInvite;
2332
use super::ApiScopeMember;
33+
use super::ApiToken;
2434

2535
pub fn self_user_router() -> Router<Body, ApiError> {
2636
Router::builder()
@@ -33,6 +43,9 @@ pub fn self_user_router() -> Router<Body, ApiError> {
3343
util::auth(util::json(accept_invite_handler)),
3444
)
3545
.delete("/invites/:scope", util::auth(decline_invite_handler))
46+
.get("/tokens", util::auth(util::json(list_tokens)))
47+
.post("/tokens", util::auth(util::json(create_token)))
48+
.delete("/tokens/:id", util::auth(util::json(delete_token)))
3649
.build()
3750
.unwrap()
3851
}
@@ -151,3 +164,110 @@ pub async fn decline_invite_handler(
151164
.unwrap();
152165
Ok(resp)
153166
}
167+
168+
#[instrument("GET /api/user/tokens")]
169+
async fn list_tokens(req: Request<Body>) -> Result<Vec<ApiToken>, ApiError> {
170+
let iam = req.iam();
171+
let user = iam.check_current_user_access()?;
172+
173+
let db = req.data::<Database>().unwrap();
174+
175+
let tokens = db.list_tokens(user.id).await?;
176+
177+
Ok(tokens.into_iter().map(ApiToken::from).collect())
178+
}
179+
180+
#[instrument("POST /api/user/tokens")]
181+
async fn create_token(
182+
mut req: Request<Body>,
183+
) -> Result<ApiCreatedToken, ApiError> {
184+
let ApiCreateTokenRequest {
185+
description,
186+
expires_at,
187+
permissions,
188+
} = decode_json(&mut req).await?;
189+
190+
let description = description.trim().replace('\n', " ").replace('\r', "");
191+
if description.is_empty() {
192+
return Err(ApiError::MalformedRequest {
193+
msg: "description must not be empty".into(),
194+
});
195+
}
196+
if description.len() > 250 {
197+
return Err(ApiError::MalformedRequest {
198+
msg: "description must not be longer than 250 characters".into(),
199+
});
200+
}
201+
if description.contains(|c: char| c.is_control()) {
202+
return Err(ApiError::MalformedRequest {
203+
msg: "description must not contain control characters".into(),
204+
});
205+
}
206+
207+
if let Some(permissions) = permissions.as_ref() {
208+
if permissions.0.len() != 1 {
209+
return Err(ApiError::MalformedRequest {
210+
msg: "permissions must contain exactly one element".into(),
211+
});
212+
}
213+
}
214+
215+
let iam = req.iam();
216+
let user = iam.check_authorization_approve_access()?;
217+
218+
let db = req.data::<Database>().unwrap();
219+
220+
let secret = crate::token::create_token(
221+
db,
222+
user.id,
223+
TokenType::Personal,
224+
Some(description),
225+
expires_at,
226+
permissions,
227+
)
228+
.await?;
229+
230+
let hash = crate::token::hash(&secret);
231+
let token = db.get_token_by_hash(&hash).await?.unwrap();
232+
233+
if let Some(ref email) = user.email {
234+
let email_sender = req.data::<Option<EmailSender>>().unwrap();
235+
let registry_url = req.data::<RegistryUrl>().unwrap();
236+
if let Some(email_sender) = email_sender {
237+
let email_args = EmailArgs::PersonalAccessToken {
238+
name: Cow::Borrowed(&user.name),
239+
registry_url: Cow::Borrowed(registry_url.0.as_str()),
240+
registry_name: Cow::Borrowed(&email_sender.from_name),
241+
support_email: Cow::Borrowed(&email_sender.from),
242+
};
243+
email_sender
244+
.send(email.clone(), email_args)
245+
.await
246+
.map_err(|e| {
247+
tracing::error!("failed to send email: {:?}", e);
248+
ApiError::InternalServerError
249+
})?;
250+
}
251+
}
252+
253+
Ok(ApiCreatedToken {
254+
token: token.into(),
255+
secret,
256+
})
257+
}
258+
259+
#[instrument("DELETE /api/user/tokens/:id")]
260+
async fn delete_token(req: Request<Body>) -> Result<(), ApiError> {
261+
let id = req.param_uuid("id")?;
262+
263+
let iam = req.iam();
264+
let user = iam.check_authorization_approve_access()?;
265+
266+
let db = req.data::<Database>().unwrap();
267+
268+
if !db.delete_token(user.id, id).await? {
269+
return Err(ApiError::TokenNotFound);
270+
};
271+
272+
Ok(())
273+
}

api/src/api/types.rs

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ use serde::Serialize;
1414
use uuid::Uuid;
1515

1616
#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Copy)]
17-
#[serde(rename_all = "lowercase")]
17+
#[serde(rename_all = "snake_case")]
1818
pub enum ApiPublishingTaskStatus {
1919
Pending,
2020
Processing,
@@ -613,7 +613,7 @@ impl From<PackageVersion> for ApiPackageVersion {
613613
}
614614

615615
#[derive(Debug, Serialize, Deserialize, Ord, PartialOrd, Eq, PartialEq)]
616-
#[serde(rename_all = "camelCase")]
616+
#[serde(rename_all = "snake_case")]
617617
pub enum ApiSourceDirEntryKind {
618618
Dir,
619619
File,
@@ -628,7 +628,7 @@ pub struct ApiSourceDirEntry {
628628
}
629629

630630
#[derive(Debug, Serialize, Deserialize)]
631-
#[serde(rename_all = "camelCase", tag = "kind")]
631+
#[serde(rename_all = "snake_case", tag = "kind")]
632632
pub enum ApiSource {
633633
Dir { entries: Vec<ApiSourceDirEntry> },
634634
File { size: usize, view: Option<String> },
@@ -783,7 +783,7 @@ impl From<Authorization> for ApiAuthorization {
783783
}
784784

785785
#[derive(Debug, Serialize, Deserialize, Eq, PartialEq, Hash)]
786-
#[serde(rename_all = "camelCase")]
786+
#[serde(rename_all = "snake_case")]
787787
pub enum ApiDependencyKind {
788788
Jsr,
789789
Npm,
@@ -843,3 +843,64 @@ pub struct ApiList<T> {
843843
pub items: Vec<T>,
844844
pub total: usize,
845845
}
846+
847+
#[derive(Debug, Serialize, Deserialize)]
848+
#[serde(rename_all = "snake_case")]
849+
pub enum ApiTokenType {
850+
Web,
851+
Device,
852+
Personal,
853+
}
854+
855+
impl From<TokenType> for ApiTokenType {
856+
fn from(value: TokenType) -> Self {
857+
match value {
858+
TokenType::Web => ApiTokenType::Web,
859+
TokenType::Device => ApiTokenType::Device,
860+
TokenType::Personal => ApiTokenType::Personal,
861+
}
862+
}
863+
}
864+
865+
#[derive(Debug, Serialize, Deserialize)]
866+
#[serde(rename_all = "camelCase")]
867+
pub struct ApiToken {
868+
pub id: Uuid,
869+
pub description: Option<String>,
870+
pub user_id: Uuid,
871+
pub r#type: ApiTokenType,
872+
pub expires_at: Option<DateTime<Utc>>,
873+
pub updated_at: DateTime<Utc>,
874+
pub created_at: DateTime<Utc>,
875+
pub permissions: Option<Permissions>,
876+
}
877+
878+
impl From<Token> for ApiToken {
879+
fn from(value: Token) -> Self {
880+
Self {
881+
id: value.id,
882+
description: value.description,
883+
user_id: value.user_id,
884+
r#type: value.r#type.into(),
885+
expires_at: value.expires_at,
886+
updated_at: value.updated_at,
887+
created_at: value.created_at,
888+
permissions: value.permissions,
889+
}
890+
}
891+
}
892+
893+
#[derive(Debug, Serialize, Deserialize)]
894+
#[serde(rename_all = "camelCase")]
895+
pub struct ApiCreateTokenRequest {
896+
pub description: String,
897+
pub expires_at: Option<DateTime<Utc>>,
898+
pub permissions: Option<Permissions>,
899+
}
900+
901+
#[derive(Debug, Serialize, Deserialize)]
902+
#[serde(rename_all = "camelCase")]
903+
pub struct ApiCreatedToken {
904+
pub secret: String,
905+
pub token: ApiToken,
906+
}

api/src/config.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ impl std::fmt::Debug for Config {
173173
&self.postmark_token.as_ref().map(|_| "***"),
174174
)
175175
.field("email_from", &self.email_from)
176+
.field("email_from_name", &self.email_from_name)
176177
.finish()
177178
}
178179
}

api/src/db/database.rs

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2567,13 +2567,41 @@ impl Database {
25672567
.await
25682568
}
25692569

2570-
#[instrument(name = "Database::get_token", skip(self), err)]
2571-
pub async fn get_token(&self, hash: &str) -> Result<Option<Token>> {
2570+
#[instrument(name = "Database::get_token_by_hash", skip(self), err)]
2571+
pub async fn get_token_by_hash(&self, hash: &str) -> Result<Option<Token>> {
25722572
sqlx::query_as!(Token, r#"SELECT id, hash, user_id, type "type: _", description, expires_at, permissions "permissions: _", updated_at, created_at FROM tokens WHERE hash = $1"#, hash)
25732573
.fetch_optional(&self.pool)
25742574
.await
25752575
}
25762576

2577+
#[instrument(name = "Database::list_token", skip(self), err)]
2578+
pub async fn list_tokens(&self, user_id: Uuid) -> Result<Vec<Token>> {
2579+
// list a user's tokens where the expiration date is at most 1 day in the past
2580+
sqlx::query_as!(
2581+
Token,
2582+
r#"SELECT id, hash, user_id, type "type: _", description, expires_at, permissions "permissions: _", updated_at, created_at
2583+
FROM tokens
2584+
WHERE user_id = $1 AND (expires_at > now() - interval '1 day' OR expires_at IS NULL)
2585+
ORDER BY expires_at DESC NULLS FIRST, created_at DESC
2586+
"#,
2587+
user_id
2588+
)
2589+
.fetch_all(&self.pool)
2590+
.await
2591+
}
2592+
2593+
#[instrument(name = "Database::delete_token", skip(self), err)]
2594+
pub async fn delete_token(&self, user_id: Uuid, id: Uuid) -> Result<bool> {
2595+
let res = sqlx::query!(
2596+
r#"DELETE FROM tokens WHERE user_id = $1 ANd id = $2"#,
2597+
user_id,
2598+
id
2599+
)
2600+
.execute(&self.pool)
2601+
.await?;
2602+
Ok(res.rows_affected() > 0)
2603+
}
2604+
25772605
#[instrument(
25782606
name = "Database::create_authorization",
25792607
skip(self, new_authorization),

0 commit comments

Comments
 (0)