App password scopes

This commit is contained in:
lewis
2025-12-25 21:17:09 +02:00
parent a3b5f6135a
commit 3f727b1c9d
38 changed files with 557 additions and 153 deletions
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE oauth_token\n SET token_id = $2, current_refresh_token = $3, expires_at = $4, updated_at = NOW()\n WHERE id = $1\n ",
"query": "\n UPDATE oauth_token\n SET token_id = $2, current_refresh_token = $3, expires_at = $4, updated_at = NOW(),\n previous_refresh_token = $5, rotated_at = NOW()\n WHERE id = $1\n ",
"describe": {
"columns": [],
"parameters": {
@@ -8,10 +8,11 @@
"Int4",
"Text",
"Text",
"Timestamptz"
"Timestamptz",
"Text"
]
},
"nullable": []
},
"hash": "b9b57cad3948c2883a05c22ba918232d066fe8cb6f67410a4b4ef99d80386284"
"hash": "2f5fb86d249903ea40240658b4f8fd5a8d96120e92d791ff446b441f9222f00f"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT password_hash, scopes FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "password_hash",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "scopes",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
true
]
},
"hash": "3d5ab47cdcb0d04b0a0d63c2d5a0cc45889ff4330b500ba7e77eac06ee9606c9"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT access_expires_at FROM session_tokens WHERE did = $1 AND access_jti = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "access_expires_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "44aec49f4ccd1f816c5427af2dc18f5acd55fac92c46ada76be1199d5c7ac459"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT st.id, st.did, k.key_bytes, k.encryption_version\n FROM session_tokens st\n JOIN users u ON st.did = u.did\n JOIN user_keys k ON u.id = k.user_id\n WHERE st.refresh_jti = $1 AND st.refresh_expires_at > NOW()\n FOR UPDATE OF st",
"query": "SELECT st.id, st.did, st.scope, k.key_bytes, k.encryption_version\n FROM session_tokens st\n JOIN users u ON st.did = u.did\n JOIN user_keys k ON u.id = k.user_id\n WHERE st.refresh_jti = $1 AND st.refresh_expires_at > NOW()\n FOR UPDATE OF st",
"describe": {
"columns": [
{
@@ -15,11 +15,16 @@
},
{
"ordinal": 2,
"name": "scope",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "key_bytes",
"type_info": "Bytea"
},
{
"ordinal": 3,
"ordinal": 4,
"name": "encryption_version",
"type_info": "Int4"
}
@@ -32,9 +37,10 @@
"nullable": [
false,
false,
true,
false,
true
]
},
"hash": "e2e51654f146a3a336f5a28cbd47addbdd311aeaead530c00c1891c95bede0b8"
"hash": "6b0245cefaec65a48c51239ed099e45c5347224c81f7d01d7af5bd7664d16883"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified) VALUES ($1, $2, $3, $4, $5, $6, $7)",
"query": "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified, scope) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
"describe": {
"columns": [],
"parameters": {
@@ -11,10 +11,11 @@
"Timestamptz",
"Timestamptz",
"Bool",
"Bool"
"Bool",
"Text"
]
},
"nullable": []
},
"hash": "301a8e352f7ebae1748ce1dc05860cef459764ca3c38b97693f00d67fd6bdd7e"
"hash": "815bef7ea956cf53f10728a0edfd6064784e994e94c64e482306f803b3746f24"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO app_passwords (user_id, name, password_hash, created_at, privileged) VALUES ($1, $2, $3, $4, $5)",
"query": "INSERT INTO app_passwords (user_id, name, password_hash, created_at, privileged, scopes) VALUES ($1, $2, $3, $4, $5, $6)",
"describe": {
"columns": [],
"parameters": {
@@ -9,10 +9,11 @@
"Text",
"Text",
"Timestamptz",
"Bool"
"Bool",
"Text"
]
},
"nullable": []
},
"hash": "15a3cb31c36192c76c0cfa881043d70a1cc2c212fa382f8d9efc3c35ea4e66c1"
"hash": "8d634d6c3306424ed9239f078a4892245f4b73049037ea8f3cf23fc377b57a40"
}
@@ -1,23 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT 1 as one FROM session_tokens WHERE did = $1 AND access_jti = $2 AND access_expires_at > NOW()",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "one",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "d69f93ad69fe627d6939dced19b752efc49f6a807a0ae21ebf682433a0d63dd7"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT password_hash FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "password_hash",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false
]
},
"hash": "dcaedeec794a63ce8abb9b580461c193ad58fee110d57249f98355b40b757a37"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT name, created_at, privileged FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC",
"query": "SELECT name, created_at, privileged, scopes FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC",
"describe": {
"columns": [
{
@@ -17,6 +17,11 @@
"ordinal": 2,
"name": "privileged",
"type_info": "Bool"
},
{
"ordinal": 3,
"name": "scopes",
"type_info": "Text"
}
],
"parameters": {
@@ -27,8 +32,9 @@
"nullable": [
false,
false,
false
false,
true
]
},
"hash": "cec87a805457bcac6db8be601861b351a9332c649433894547176f6e4d672d01"
"hash": "f47f2236dcc27bc203b0cd13cc022611492f0f82c572c5a536663e8d252cfafb"
}
@@ -0,0 +1,101 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, did, token_id, created_at, updated_at, expires_at, client_id, client_auth,\n device_id, parameters, details, code, current_refresh_token, scope\n FROM oauth_token\n WHERE previous_refresh_token = $1 AND rotated_at > $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "did",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "token_id",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "updated_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "expires_at",
"type_info": "Timestamptz"
},
{
"ordinal": 6,
"name": "client_id",
"type_info": "Text"
},
{
"ordinal": 7,
"name": "client_auth",
"type_info": "Jsonb"
},
{
"ordinal": 8,
"name": "device_id",
"type_info": "Text"
},
{
"ordinal": 9,
"name": "parameters",
"type_info": "Jsonb"
},
{
"ordinal": 10,
"name": "details",
"type_info": "Jsonb"
},
{
"ordinal": 11,
"name": "code",
"type_info": "Text"
},
{
"ordinal": 12,
"name": "current_refresh_token",
"type_info": "Text"
},
{
"ordinal": 13,
"name": "scope",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Timestamptz"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
true,
false,
true,
true,
true,
true
]
},
"hash": "fd291f783059a00c2ac29920bcb5f12a0553148d8a216eb21dd0e63d5a4b1913"
}
+3 -2
View File
@@ -14,7 +14,7 @@ Another excellent PDS is [Cocoon](https://github.com/haileyok/cocoon), written i
This software isn't an afterthought by a company with limited resources.
It is a superset of the reference PDS, including: passkeys and 2FA (WebAuthn/FIDO2, TOTP, backup codes, trusted devices), did:web support (PDS-hosted subdomains or bring-your-own), multi-channel communication (email, discord, telegram, signal) for verification and alerts, granular OAuth scopes with a consent UI showing human-readable descriptions, and a built-in web UI for account management, OAuth consent, repo browsing, and admin.
It is a superset of the reference PDS, including: passkeys and 2FA (WebAuthn/FIDO2, TOTP, backup codes, trusted devices), did:web support (PDS-hosted subdomains or bring-your-own), multi-channel communication (email, discord, telegram, signal) for verification and alerts, granular OAuth scopes with a consent UI showing human-readable descriptions, app passwords with granular permissions (read-only, post-only, or custom scopes), and a built-in web UI for account management, OAuth consent, repo browsing, and admin.
The PDS itself is a single small binary with no node/npm runtime. It does require postgres, valkey, and s3-compatible storage, which makes setup heavier than the reference PDS's sqlite. The tradeoff is that these are battle-tested pieces of infra that we already know how to scale, back up, and monitor.
@@ -66,4 +66,5 @@ This project is very grateful to [@nel.pet](https://bsky.app/profile/did:plc:h5w
## License
TBD
AGPL-3.0-or-later. Documentation is CC BY-SA 4.0. See [LICENSE](LICENSE) for details.
+2 -7
View File
@@ -2,13 +2,6 @@
## Active development
### Frontend
So like... make the thing unique, make it cool.
- [ ] Frontpage that explains what this thing is
- [ ] Unique "brand" style both unauthed and authed
- [ ] Better documentation on how to sub out the entire frontend for whatever the users want
### Delegated accounts
Accounts controlled by other accounts rather than having their own password. When logging in as a delegated account, OAuth asks you to authenticate with a linked controller account. Uses OAuth scopes as the permission model.
@@ -90,3 +83,5 @@ Web UI: OAuth login, registration, email verification, password reset, multi-acc
Auth: ES256K + HS256 dual support, JTI-only token storage, refresh token family tracking, encrypted signing keys (AES-256-GCM), DPoP replay protection, constant-time comparisons.
Passkeys and 2FA: WebAuthn/FIDO2 passkey registration and authentication, TOTP with QR setup, backup codes (hashed, one-time use), passkey-only account creation, trusted devices (remember this browser), re-auth for sensitive actions, rate-limited 2FA attempts, settings UI for managing all auth methods.
App password scopes: Granular permissions for app passwords using the same scope system as OAuth. Preset buttons for common use cases (full access, read-only, post-only), scope stored in session and preserved across token refresh, explicit RPC/repo/blob scope enforcement for restricted passwords.
+6 -2
View File
@@ -93,6 +93,7 @@ export interface Session {
export interface AppPassword {
name: string;
createdAt: string;
scopes?: string;
}
export interface InviteCode {
@@ -226,11 +227,14 @@ export const api = {
async createAppPassword(
token: string,
name: string,
): Promise<{ name: string; password: string; createdAt: string }> {
scopes?: string,
): Promise<
{ name: string; password: string; createdAt: string; scopes?: string }
> {
return xrpc("com.atproto.server.createAppPassword", {
method: "POST",
token,
body: { name },
body: { name, scopes },
});
},
+6 -1
View File
@@ -266,7 +266,12 @@
"revokeConfirm": "Revoke app password \"{name}\"? Apps using this password will no longer be able to access your account.",
"saveWarningTitle": "Important: Save this app password!",
"saveWarningMessage": "This password is required to sign into apps that don't support passkeys or OAuth. You will only see it once.",
"acknowledgeLabel": "I have saved my app password in a secure location"
"acknowledgeLabel": "I have saved my app password in a secure location",
"permissions": "Permissions",
"scopeFull": "Full Access",
"scopeReadOnly": "Read Only",
"scopePostOnly": "Post Only",
"scopeCustom": "Custom"
},
"sessions": {
"title": "Active Sessions",
+6 -1
View File
@@ -266,7 +266,12 @@
"revokeConfirm": "Peruuta sovelluksen salasana \"{name}\"? Sovellukset, jotka käyttävät tätä salasanaa, eivät enää pääse tilillesi.",
"saveWarningTitle": "Tärkeää: Tallenna tämä sovelluksen salasana!",
"saveWarningMessage": "Tämä salasana tarvitaan kirjautumiseen sovelluksiin, jotka eivät tue pääsyavaimia tai OAuthia. Näet sen vain kerran.",
"acknowledgeLabel": "Olen tallentanut sovelluksen salasanani turvalliseen paikkaan"
"acknowledgeLabel": "Olen tallentanut sovelluksen salasanani turvalliseen paikkaan",
"permissions": "Käyttöoikeudet",
"scopeFull": "Täydet oikeudet",
"scopeReadOnly": "Vain luku",
"scopePostOnly": "Vain julkaisut",
"scopeCustom": "Mukautettu"
},
"sessions": {
"title": "Aktiiviset istunnot",
+6 -1
View File
@@ -266,7 +266,12 @@
"revokeConfirm": "アプリパスワード「{name}」を取り消しますか?このパスワードを使用しているアプリはアカウントにアクセスできなくなります。",
"saveWarningTitle": "重要: このアプリパスワードを保存してください!",
"saveWarningMessage": "このパスワードはパスキーや OAuth をサポートしていないアプリにサインインするために必要です。一度しか表示されません。",
"acknowledgeLabel": "アプリパスワードを安全な場所に保存しました"
"acknowledgeLabel": "アプリパスワードを安全な場所に保存しました",
"permissions": "権限",
"scopeFull": "フルアクセス",
"scopeReadOnly": "読み取り専用",
"scopePostOnly": "投稿のみ",
"scopeCustom": "カスタム"
},
"sessions": {
"title": "アクティブセッション",
+6 -1
View File
@@ -266,7 +266,12 @@
"revokeConfirm": "앱 비밀번호 \"{name}\"을(를) 취소하시겠습니까? 이 비밀번호를 사용하는 앱은 더 이상 계정에 액세스할 수 없습니다.",
"saveWarningTitle": "중요: 이 앱 비밀번호를 저장하세요!",
"saveWarningMessage": "이 비밀번호는 패스키 또는 OAuth를 지원하지 않는 앱에 로그인하는 데 필요합니다. 한 번만 볼 수 있습니다.",
"acknowledgeLabel": "앱 비밀번호를 안전한 곳에 저장했습니다"
"acknowledgeLabel": "앱 비밀번호를 안전한 곳에 저장했습니다",
"permissions": "권한",
"scopeFull": "전체 권한",
"scopeReadOnly": "읽기 전용",
"scopePostOnly": "게시만 가능",
"scopeCustom": "사용자 지정"
},
"sessions": {
"title": "활성 세션",
+6 -1
View File
@@ -266,7 +266,12 @@
"revokeConfirm": "Återkalla applösenord \"{name}\"? Appar som använder detta lösenord kommer inte längre att kunna komma åt ditt konto.",
"saveWarningTitle": "Viktigt: Spara detta applösenord!",
"saveWarningMessage": "Detta lösenord krävs för att logga in i appar som inte stöder passkeys eller OAuth. Du ser det bara en gång.",
"acknowledgeLabel": "Jag har sparat mitt applösenord på en säker plats"
"acknowledgeLabel": "Jag har sparat mitt applösenord på en säker plats",
"permissions": "Behörigheter",
"scopeFull": "Full åtkomst",
"scopeReadOnly": "Endast läsning",
"scopePostOnly": "Endast publicering",
"scopeCustom": "Anpassad"
},
"sessions": {
"title": "Aktiva sessioner",
+6 -1
View File
@@ -266,7 +266,12 @@
"revokeConfirm": "撤销「{name}」的密码?使用此密码的应用将无法再访问您的账户。",
"saveWarningTitle": "重要:请保存此应用专用密码!",
"saveWarningMessage": "此密码用于登录不支持通行密钥或 OAuth 的应用。您只能看到一次。",
"acknowledgeLabel": "我已将应用专用密码保存在安全的地方"
"acknowledgeLabel": "我已将应用专用密码保存在安全的地方",
"permissions": "权限",
"scopeFull": "完全访问",
"scopeReadOnly": "只读",
"scopePostOnly": "仅发帖",
"scopeCustom": "自定义"
},
"sessions": {
"title": "登录会话",
+109 -4
View File
@@ -9,11 +9,25 @@
let loading = $state(true)
let error = $state<string | null>(null)
let newPasswordName = $state('')
let selectedScope = $state<string | null>(null)
let creating = $state(false)
let createdPassword = $state<{ name: string; password: string } | null>(null)
let passwordCopied = $state(false)
let passwordAcknowledged = $state(false)
let revoking = $state<string | null>(null)
const SCOPE_PRESETS = [
{ id: 'full', label: 'appPasswords.scopeFull', scopes: null },
{ id: 'readonly', label: 'appPasswords.scopeReadOnly', scopes: 'rpc:app.bsky.*?aud=* rpc:chat.bsky.*?aud=* account:status?action=read' },
{ id: 'post', label: 'appPasswords.scopePostOnly', scopes: 'repo:app.bsky.feed.post?action=create blob:*/*' },
]
function getScopeLabel(scopes: string | null | undefined): string {
if (!scopes) return $_('appPasswords.scopeFull')
const preset = SCOPE_PRESETS.find(p => p.scopes === scopes)
if (preset) return $_(preset.label)
return $_('appPasswords.scopeCustom')
}
$effect(() => {
if (!auth.loading && !auth.session) {
navigate('/login')
@@ -43,9 +57,11 @@
creating = true
error = null
try {
const result = await api.createAppPassword(auth.session.accessJwt, newPasswordName.trim())
const scopeValue = selectedScope === null ? undefined : selectedScope
const result = await api.createAppPassword(auth.session.accessJwt, newPasswordName.trim(), scopeValue ?? undefined)
createdPassword = { name: result.name, password: result.password }
newPasswordName = ''
selectedScope = null
await loadPasswords()
} catch (e) {
error = e instanceof ApiError ? e.message : 'Failed to create app password'
@@ -122,6 +138,22 @@
disabled={creating}
required
/>
<div class="scope-selector" role="group" aria-label={$_('appPasswords.permissions')}>
<span class="scope-label">{$_('appPasswords.permissions')}:</span>
<div class="scope-buttons">
{#each SCOPE_PRESETS as preset}
<button
type="button"
class="scope-btn"
class:selected={selectedScope === preset.scopes}
onclick={() => selectedScope = preset.scopes}
disabled={creating}
>
{$_(preset.label)}
</button>
{/each}
</div>
</div>
<button type="submit" disabled={creating || !newPasswordName.trim()}>
{creating ? $_('appPasswords.creating') : $_('common.create')}
</button>
@@ -139,7 +171,10 @@
<li>
<div class="password-info">
<span class="name">{pw.name}</span>
<span class="date">{$_('common.created')} {formatDate(pw.createdAt)}</span>
<span class="meta">
<span class="scope-badge" class:full={!pw.scopes}>{getScopeLabel(pw.scopes)}</span>
<span class="date">{$_('common.created')} {formatDate(pw.createdAt)}</span>
</span>
</div>
<button
class="revoke"
@@ -279,11 +314,60 @@
.create-section form {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.create-section form > input {
flex: 1;
}
.create-section form > button {
align-self: flex-start;
}
.scope-selector {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.create-section input {
flex: 1;
.scope-label {
font-size: var(--text-sm);
color: var(--text-secondary);
}
.scope-buttons {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.scope-btn {
padding: var(--space-2) var(--space-4);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
color: var(--text-primary);
cursor: pointer;
font-size: var(--text-sm);
transition: all 0.15s ease;
}
.scope-btn:hover:not(:disabled) {
background: var(--bg-hover);
border-color: var(--accent);
}
.scope-btn.selected {
background: var(--accent);
border-color: var(--accent);
color: var(--text-inverse);
}
.scope-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.password-list {
@@ -313,6 +397,27 @@
font-weight: var(--font-medium);
}
.meta {
display: flex;
align-items: center;
gap: var(--space-3);
}
.scope-badge {
font-size: var(--text-xs);
padding: var(--space-1) var(--space-2);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-secondary);
}
.scope-badge.full {
background: var(--success-bg);
border-color: var(--success-border);
color: var(--success-text);
}
.date {
font-size: var(--text-sm);
color: var(--text-secondary);
+5
View File
@@ -173,6 +173,11 @@
<h3>You decide what apps can do</h3>
<p>When an app asks for access, you'll see exactly what it wants in plain language. Grant what makes sense, deny what doesn't.</p>
</div>
<div class="feature">
<h3>App passwords with guardrails</h3>
<p>Create app passwords that can only do specific things: read-only for feed readers, post-only for bots. Full control over what each password can access.</p>
</div>
</div>
<h2>Everything in one place</h2>
@@ -0,0 +1 @@
ALTER TABLE app_passwords ADD COLUMN scopes TEXT;
@@ -0,0 +1 @@
ALTER TABLE session_tokens ADD COLUMN scope TEXT;
@@ -0,0 +1,2 @@
ALTER TABLE oauth_token ADD COLUMN previous_refresh_token TEXT;
ALTER TABLE oauth_token ADD COLUMN rotated_at TIMESTAMPTZ;
+10
View File
@@ -94,6 +94,16 @@ pub async fn proxy_handler(
}
Err(e) => {
warn!("Token validation failed: {:?}", e);
if matches!(e, crate::auth::TokenValidationError::TokenExpired) {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "ExpiredToken",
"message": "Token has expired"
})),
)
.into_response();
}
}
}
}
+11 -2
View File
@@ -19,7 +19,7 @@ use serde::{Deserialize, Serialize};
use serde_json::json;
use std::str::FromStr;
use std::sync::Arc;
use tracing::error;
use tracing::{error, info};
const MAX_BATCH_WRITES: usize = 200;
@@ -79,6 +79,11 @@ pub async fn apply_writes(
headers: axum::http::HeaderMap,
Json(input): Json<ApplyWritesInput>,
) -> Response {
info!(
"apply_writes called: repo={}, writes={}",
input.repo,
input.writes.len()
);
let token = match crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok()),
) {
@@ -147,7 +152,11 @@ pub async fn apply_writes(
.into_response();
}
if is_oauth {
let has_custom_scope = scope
.as_ref()
.map(|s| s != "com.atproto.access")
.unwrap_or(false);
if is_oauth || has_custom_scope {
use std::collections::HashSet;
let create_collections: HashSet<&str> = input
.writes
+4 -4
View File
@@ -16,10 +16,10 @@ pub fn create_signed_commit(
prev: Option<Cid>,
signing_key: &SigningKey,
) -> Result<(Vec<u8>, Bytes), String> {
let did = jacquard::types::string::Did::new(did)
.map_err(|e| format!("Invalid DID: {:?}", e))?;
let rev = jacquard::types::string::Tid::from_str(rev)
.map_err(|e| format!("Invalid TID: {:?}", e))?;
let did =
jacquard::types::string::Did::new(did).map_err(|e| format!("Invalid DID: {:?}", e))?;
let rev =
jacquard::types::string::Tid::from_str(rev).map_err(|e| format!("Invalid TID: {:?}", e))?;
let unsigned = Commit::new_unsigned(did, data, rev, prev);
let signed = unsigned
.sign(signing_key)
+12 -3
View File
@@ -18,6 +18,8 @@ pub struct AppPassword {
pub name: String,
pub created_at: String,
pub privileged: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub scopes: Option<String>,
}
#[derive(Serialize)]
@@ -34,7 +36,7 @@ pub async fn list_app_passwords(
Err(e) => return ApiError::from(e).into_response(),
};
match sqlx::query!(
"SELECT name, created_at, privileged FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC",
"SELECT name, created_at, privileged, scopes FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC",
user_id
)
.fetch_all(&state.db)
@@ -47,6 +49,7 @@ pub async fn list_app_passwords(
name: row.name.clone(),
created_at: row.created_at.to_rfc3339(),
privileged: row.privileged,
scopes: row.scopes.clone(),
})
.collect();
Json(ListAppPasswordsOutput { passwords }).into_response()
@@ -62,6 +65,7 @@ pub async fn list_app_passwords(
pub struct CreateAppPasswordInput {
pub name: String,
pub privileged: Option<bool>,
pub scopes: Option<String>,
}
#[derive(Serialize)]
@@ -71,6 +75,8 @@ pub struct CreateAppPasswordOutput {
pub password: String,
pub created_at: String,
pub privileged: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub scopes: Option<String>,
}
pub async fn create_app_password(
@@ -131,14 +137,16 @@ pub async fn create_app_password(
}
};
let privileged = input.privileged.unwrap_or(false);
let scopes = input.scopes.clone();
let created_at = chrono::Utc::now();
match sqlx::query!(
"INSERT INTO app_passwords (user_id, name, password_hash, created_at, privileged) VALUES ($1, $2, $3, $4, $5)",
"INSERT INTO app_passwords (user_id, name, password_hash, created_at, privileged, scopes) VALUES ($1, $2, $3, $4, $5, $6)",
user_id,
name,
password_hash,
created_at,
privileged
privileged,
scopes
)
.execute(&state.db)
.await
@@ -148,6 +156,7 @@ pub async fn create_app_password(
password,
created_at: created_at.to_rfc3339(),
privileged,
scopes,
})
.into_response(),
Err(e) => {
+33 -19
View File
@@ -125,24 +125,28 @@ pub async fn create_session(
return ApiError::InternalError.into_response();
}
};
let password_valid = if row
let (password_valid, app_password_scopes) = if row
.password_hash
.as_ref()
.map(|h| verify(&input.password, h).unwrap_or(false))
.unwrap_or(false)
{
true
(true, None)
} else {
let app_passwords = sqlx::query!(
"SELECT password_hash FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20",
"SELECT password_hash, scopes FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20",
row.id
)
.fetch_all(&state.db)
.await
.unwrap_or_default();
app_passwords
let matched = app_passwords
.iter()
.any(|app| verify(&input.password, &app.password_hash).unwrap_or(false))
.find(|app| verify(&input.password, &app.password_hash).unwrap_or(false));
match matched {
Some(app) => (true, app.scopes.clone()),
None => (false, None),
}
};
if !password_valid {
warn!("Password verification failed for login attempt");
@@ -177,7 +181,11 @@ pub async fn create_session(
)
.into_response();
}
let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) {
let access_meta = match crate::auth::create_access_token_with_scope_metadata(
&row.did,
&key_bytes,
app_password_scopes.as_deref(),
) {
Ok(m) => m,
Err(e) => {
error!("Failed to create access token: {:?}", e);
@@ -192,14 +200,15 @@ pub async fn create_session(
}
};
if let Err(e) = sqlx::query!(
"INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified) VALUES ($1, $2, $3, $4, $5, $6, $7)",
"INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified, scope) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
row.did,
access_meta.jti,
refresh_meta.jti,
access_meta.expires_at,
refresh_meta.expires_at,
is_legacy_login,
false
false,
app_password_scopes
)
.execute(&state.db)
.await
@@ -388,7 +397,7 @@ pub async fn refresh_session(
.into_response();
}
let session_row = match sqlx::query!(
r#"SELECT st.id, st.did, k.key_bytes, k.encryption_version
r#"SELECT st.id, st.did, st.scope, k.key_bytes, k.encryption_version
FROM session_tokens st
JOIN users u ON st.did = u.did
JOIN user_keys k ON u.id = k.user_id
@@ -420,14 +429,17 @@ pub async fn refresh_session(
if crate::auth::verify_refresh_token(&refresh_token, &key_bytes).is_err() {
return ApiError::AuthenticationFailedMsg("Invalid refresh token".into()).into_response();
}
let new_access_meta =
match crate::auth::create_access_token_with_metadata(&session_row.did, &key_bytes) {
Ok(m) => m,
Err(e) => {
error!("Failed to create access token: {:?}", e);
return ApiError::InternalError.into_response();
}
};
let new_access_meta = match crate::auth::create_access_token_with_scope_metadata(
&session_row.did,
&key_bytes,
session_row.scope.as_deref(),
) {
Ok(m) => m,
Err(e) => {
error!("Failed to create access token: {:?}", e);
return ApiError::InternalError.into_response();
}
};
let new_refresh_meta =
match crate::auth::create_refresh_token_with_metadata(&session_row.did, &key_bytes) {
Ok(m) => m,
@@ -653,15 +665,17 @@ pub async fn confirm_signup(
return ApiError::InternalError.into_response();
}
};
let no_scope: Option<String> = None;
if let Err(e) = sqlx::query!(
"INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified) VALUES ($1, $2, $3, $4, $5, $6, $7)",
"INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified, scope) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
row.did,
access_meta.jti,
refresh_meta.jti,
access_meta.expires_at,
refresh_meta.expires_at,
false,
false
false,
no_scope
)
.execute(&state.db)
.await
+28 -15
View File
@@ -24,8 +24,8 @@ pub use service::{ServiceTokenClaims, ServiceTokenVerifier, is_service_token};
pub use token::{
SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED, SCOPE_REFRESH, TOKEN_TYPE_ACCESS,
TOKEN_TYPE_REFRESH, TOKEN_TYPE_SERVICE, TokenWithMetadata, create_access_token,
create_access_token_with_metadata, create_refresh_token, create_refresh_token_with_metadata,
create_service_token,
create_access_token_with_metadata, create_access_token_with_scope_metadata,
create_refresh_token, create_refresh_token_with_metadata, create_service_token,
};
pub use verify::{
TokenVerifyError, get_did_from_token, get_jti_from_token, verify_access_token,
@@ -66,6 +66,11 @@ pub struct AuthenticatedUser {
impl AuthenticatedUser {
pub fn permissions(&self) -> ScopePermissions {
if let Some(ref scope) = self.scope
&& scope != SCOPE_ACCESS
{
return ScopePermissions::from_scope_string(Some(scope));
}
if !self.is_oauth {
return ScopePermissions::from_scope_string(Some("atproto"));
}
@@ -212,8 +217,8 @@ async fn validate_bearer_token_with_options_internal(
}
if !session_valid {
let session_exists = sqlx::query_scalar!(
"SELECT 1 as one FROM session_tokens WHERE did = $1 AND access_jti = $2 AND access_expires_at > NOW()",
let session_row = sqlx::query!(
"SELECT access_expires_at FROM session_tokens WHERE did = $1 AND access_jti = $2",
did,
jti
)
@@ -222,16 +227,24 @@ async fn validate_bearer_token_with_options_internal(
.ok()
.flatten();
session_valid = session_exists.is_some();
if session_valid && let Some(c) = cache {
let _ = c
.set(
&session_cache_key,
"1",
Duration::from_secs(SESSION_CACHE_TTL_SECS),
)
.await;
match session_row {
Some(row) => {
if row.access_expires_at > chrono::Utc::now() {
session_valid = true;
if let Some(c) = cache {
let _ = c
.set(
&session_cache_key,
"1",
Duration::from_secs(SESSION_CACHE_TTL_SECS),
)
.await;
}
} else {
return Err(TokenValidationError::TokenExpired);
}
}
None => {}
}
}
@@ -241,7 +254,7 @@ async fn validate_bearer_token_with_options_internal(
key_bytes: Some(decrypted_key),
is_oauth: false,
is_admin,
scope: None,
scope: token_data.claims.scope.clone(),
});
}
}
+14 -5
View File
@@ -8,13 +8,22 @@ use crate::oauth::scopes::{
AccountAction, AccountAttr, IdentityAttr, RepoAction, ScopePermissions,
};
use super::token::SCOPE_ACCESS;
fn has_custom_scope(scope: Option<&str>) -> bool {
match scope {
None => false,
Some(s) => s != SCOPE_ACCESS,
}
}
pub fn check_repo_scope(
is_oauth: bool,
scope: Option<&str>,
action: RepoAction,
collection: &str,
) -> Result<(), Response> {
if !is_oauth {
if !is_oauth && !has_custom_scope(scope) {
return Ok(());
}
@@ -32,7 +41,7 @@ pub fn check_repo_scope(
}
pub fn check_blob_scope(is_oauth: bool, scope: Option<&str>, mime: &str) -> Result<(), Response> {
if !is_oauth {
if !is_oauth && !has_custom_scope(scope) {
return Ok(());
}
@@ -55,7 +64,7 @@ pub fn check_rpc_scope(
aud: &str,
lxm: &str,
) -> Result<(), Response> {
if !is_oauth {
if !is_oauth && !has_custom_scope(scope) {
return Ok(());
}
@@ -78,7 +87,7 @@ pub fn check_account_scope(
attr: AccountAttr,
action: AccountAction,
) -> Result<(), Response> {
if !is_oauth {
if !is_oauth && !has_custom_scope(scope) {
return Ok(());
}
@@ -100,7 +109,7 @@ pub fn check_identity_scope(
scope: Option<&str>,
attr: IdentityAttr,
) -> Result<(), Response> {
if !is_oauth {
if !is_oauth && !has_custom_scope(scope) {
return Ok(());
}
+10 -1
View File
@@ -33,9 +33,18 @@ pub fn create_refresh_token(did: &str, key_bytes: &[u8]) -> Result<String> {
}
pub fn create_access_token_with_metadata(did: &str, key_bytes: &[u8]) -> Result<TokenWithMetadata> {
create_access_token_with_scope_metadata(did, key_bytes, None)
}
pub fn create_access_token_with_scope_metadata(
did: &str,
key_bytes: &[u8],
scopes: Option<&str>,
) -> Result<TokenWithMetadata> {
let scope = scopes.unwrap_or(SCOPE_ACCESS);
create_signed_token_with_metadata(
did,
SCOPE_ACCESS,
scope,
TOKEN_TYPE_ACCESS,
key_bytes,
Duration::minutes(15),
+5 -7
View File
@@ -256,12 +256,7 @@ pub fn verify_access_token_typed(
token: &str,
key_bytes: &[u8],
) -> Result<TokenData<Claims>, TokenVerifyError> {
verify_token_typed_internal(
token,
key_bytes,
Some(TOKEN_TYPE_ACCESS),
Some(&[SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED]),
)
verify_token_typed_internal(token, key_bytes, Some(TOKEN_TYPE_ACCESS), None)
}
fn verify_token_typed_internal(
@@ -307,7 +302,10 @@ fn verify_token_typed_internal(
let verifying_key = VerifyingKey::from(&signing_key);
let message = format!("{}.{}", header_b64, claims_b64);
if verifying_key.verify(message.as_bytes(), &signature).is_err() {
if verifying_key
.verify(message.as_bytes(), &signature)
.is_err()
{
return Err(TokenVerifyError::Invalid);
}
+2 -1
View File
@@ -26,7 +26,8 @@ pub use scope_preference::{
pub use token::{
check_refresh_token_used, count_tokens_for_user, create_token, delete_oldest_tokens_for_user,
delete_token, delete_token_family, enforce_token_limit_for_user, get_token_by_id,
get_token_by_refresh_token, list_tokens_for_user, revoke_tokens_for_client, rotate_token,
get_token_by_previous_refresh_token, get_token_by_refresh_token, list_tokens_for_user,
revoke_tokens_for_client, rotate_token,
};
pub use two_factor::{
TwoFactorChallenge, check_user_2fa_enabled, cleanup_expired_2fa_challenges,
+47 -3
View File
@@ -122,7 +122,7 @@ pub async fn rotate_token(
)
.fetch_one(&mut *tx)
.await?;
if let Some(old_rt) = old_refresh {
if let Some(ref old_rt) = old_refresh {
sqlx::query!(
r#"
INSERT INTO oauth_used_refresh_token (refresh_token, token_id)
@@ -137,13 +137,15 @@ pub async fn rotate_token(
sqlx::query!(
r#"
UPDATE oauth_token
SET token_id = $2, current_refresh_token = $3, expires_at = $4, updated_at = NOW()
SET token_id = $2, current_refresh_token = $3, expires_at = $4, updated_at = NOW(),
previous_refresh_token = $5, rotated_at = NOW()
WHERE id = $1
"#,
old_db_id,
new_token_id,
new_refresh_token,
new_expires_at
new_expires_at,
old_refresh
)
.execute(&mut *tx)
.await?;
@@ -166,6 +168,48 @@ pub async fn check_refresh_token_used(
Ok(row)
}
const REFRESH_GRACE_PERIOD_SECS: i64 = 60;
pub async fn get_token_by_previous_refresh_token(
pool: &PgPool,
refresh_token: &str,
) -> Result<Option<(i32, TokenData)>, OAuthError> {
let grace_cutoff = Utc::now() - chrono::Duration::seconds(REFRESH_GRACE_PERIOD_SECS);
let row = sqlx::query!(
r#"
SELECT id, did, token_id, created_at, updated_at, expires_at, client_id, client_auth,
device_id, parameters, details, code, current_refresh_token, scope
FROM oauth_token
WHERE previous_refresh_token = $1 AND rotated_at > $2
"#,
refresh_token,
grace_cutoff
)
.fetch_optional(pool)
.await?;
match row {
Some(r) => Ok(Some((
r.id,
TokenData {
did: r.did,
token_id: r.token_id,
created_at: r.created_at,
updated_at: r.updated_at,
expires_at: r.expires_at,
client_id: r.client_id,
client_auth: from_json(r.client_auth)?,
device_id: r.device_id,
parameters: from_json(r.parameters)?,
details: r.details,
code: r.code,
current_refresh_token: r.current_refresh_token,
scope: r.scope,
},
))),
None => Ok(None),
}
}
pub async fn delete_token(pool: &PgPool, token_id: &str) -> Result<(), OAuthError> {
sqlx::query!(
r#"
+30
View File
@@ -175,6 +175,36 @@ pub async fn handle_refresh_token_grant(
"Refresh token grant requested"
);
if let Some(token_id) = db::check_refresh_token_used(&state.db, &refresh_token_str).await? {
if let Some((_db_id, token_data)) =
db::get_token_by_previous_refresh_token(&state.db, &refresh_token_str).await?
{
tracing::info!(
refresh_token_prefix = %&refresh_token_str[..std::cmp::min(16, refresh_token_str.len())],
"Refresh token reuse within grace period, returning existing tokens"
);
let dpop_jkt = token_data.parameters.dpop_jkt.as_deref();
let access_token = create_access_token(
&token_data.token_id,
&token_data.did,
dpop_jkt,
token_data.scope.as_deref(),
)?;
let mut response_headers = HeaderMap::new();
let config = AuthConfig::get();
let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes());
response_headers.insert("DPoP-Nonce", verifier.generate_nonce().parse().unwrap());
return Ok((
response_headers,
Json(TokenResponse {
access_token,
token_type: if dpop_jkt.is_some() { "DPoP" } else { "Bearer" }.to_string(),
expires_in: ACCESS_TOKEN_EXPIRY_SECONDS as u64,
refresh_token: token_data.current_refresh_token,
scope: token_data.scope,
sub: Some(token_data.did),
}),
));
}
tracing::warn!(
refresh_token_prefix = %&refresh_token_str[..std::cmp::min(16, refresh_token_str.len())],
"Refresh token reuse detected, revoking token family"
+2 -6
View File
@@ -309,16 +309,12 @@ pub async fn verify_new_account(client: &Client, did: &str) -> String {
let verification_code = lines
.iter()
.enumerate()
.find(|(_, line)| {
line.contains("verification code is:") || line.contains("code is:")
})
.find(|(_, line)| line.contains("verification code is:") || line.contains("code is:"))
.and_then(|(i, _)| lines.get(i + 1).map(|s| s.trim().to_string()))
.or_else(|| {
body_text
.split_whitespace()
.find(|word| {
word.contains('-') && word.chars().filter(|c| *c == '-').count() >= 3
})
.find(|word| word.contains('-') && word.chars().filter(|c| *c == '-').count() >= 3)
.map(|s| s.to_string())
})
.unwrap_or_else(|| body_text.clone());
+2 -6
View File
@@ -696,16 +696,12 @@ async fn test_refresh_token_replay_protection() {
let code = lines
.iter()
.enumerate()
.find(|(_, line)| {
line.contains("verification code is:") || line.contains("code is:")
})
.find(|(_, line)| line.contains("verification code is:") || line.contains("code is:"))
.and_then(|(i, _)| lines.get(i + 1).map(|s| s.trim().to_string()))
.or_else(|| {
body_text
.split_whitespace()
.find(|word| {
word.contains('-') && word.chars().filter(|c| *c == '-').count() >= 3
})
.find(|word| word.contains('-') && word.chars().filter(|c| *c == '-').count() >= 3)
.map(|s| s.to_string())
})
.unwrap_or_else(|| body_text.clone());