···14141515This software isn't an afterthought by a company with limited resources.
16161717-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.
1717+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), account delegation (letting others manage an account with configurable permission levels), and a built-in web UI for account management, OAuth consent, repo browsing, and admin.
18181919The 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.
2020
+2-17
TODO.md
···2233## Active development
4455-### Delegated accounts
66-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.
77-88-- [ ] Account type flag in actors table (personal | delegated)
99-- [ ] account_delegations table (delegated_did, controller_did, granted_scopes[], granted_at, granted_by, revoked_at)
1010-- [ ] Detect delegated account during authorize flow
1111-- [ ] Redirect to "authenticate as controller" instead of password prompt
1212-- [ ] Validate controller has delegation grant for this account
1313-- [ ] Issue token with intersection of (requested scopes :intersection-emoji: granted scopes)
1414-- [ ] Token includes act_as claim indicating delegation
1515-- [ ] Define standard scope sets (owner, admin, editor, viewer)
1616-- [ ] Create delegated account flow (no password, must add initial controller)
1717-- [ ] Controller management page (add/remove controllers, modify scopes)
1818-- [ ] "Act as" account switcher for users with delegation grants
1919-- [ ] Log all actions with both actor DID and controller DID
2020-- [ ] Audit log view for delegated account owners
2121-225### Migration tool
236Seamless account migration built into the UI, inspired by pdsmoover. Users shouldn't need external tools or brain surgery on half-done account states.
247···8568Passkeys 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.
86698770App 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.
7171+7272+Account Delegation: Delegated accounts controlled by other accounts instead of passwords. OAuth delegation flow (authenticate as controller), scope-based permissions (owner/admin/editor/viewer presets), scope intersection (tokens limited to granted permissions), `act` claim for delegation tracking, creating delegated account flow, controller management UI, "act as" account switcher, comprehensive audit logging with actor/controller tracking, delegation-aware OAuth consent with permission limitation notices.
+12
frontend/src/App.svelte
···2525 import OAuth2FA from './routes/OAuth2FA.svelte'
2626 import OAuthTotp from './routes/OAuthTotp.svelte'
2727 import OAuthPasskey from './routes/OAuthPasskey.svelte'
2828+ import OAuthDelegation from './routes/OAuthDelegation.svelte'
2829 import OAuthError from './routes/OAuthError.svelte'
2930 import Security from './routes/Security.svelte'
3031 import TrustedDevices from './routes/TrustedDevices.svelte'
3232+ import Controllers from './routes/Controllers.svelte'
3333+ import DelegationAudit from './routes/DelegationAudit.svelte'
3434+ import ActAs from './routes/ActAs.svelte'
3135 import Home from './routes/Home.svelte'
32363337 initI18n()
···9599 return OAuthTotp
96100 case '/oauth/passkey':
97101 return OAuthPasskey
102102+ case '/oauth/delegation':
103103+ return OAuthDelegation
98104 case '/oauth/error':
99105 return OAuthError
100106 case '/security':
101107 return Security
102108 case '/trusted-devices':
103109 return TrustedDevices
110110+ case '/controllers':
111111+ return Controllers
112112+ case '/delegation-audit':
113113+ return DelegationAudit
114114+ case '/act-as':
115115+ return ActAs
104116 default:
105117 return Home
106118 }
···66 "cancel": "취소",
77 "back": "뒤로",
88 "done": "완료",
99+ "continue": "계속",
910 "refresh": "새로고침",
1011 "create": "생성",
1112 "delete": "삭제",
···271272 "scopeFull": "전체 권한",
272273 "scopeReadOnly": "읽기 전용",
273274 "scopePostOnly": "게시만 가능",
274274- "scopeCustom": "사용자 지정"
275275+ "scopeCustom": "사용자 지정",
276276+ "byController": "컨트롤러 생성"
275277 },
276278 "sessions": {
277279 "title": "활성 세션",
···888890 "codeLabel": "인증 코드",
889891 "codeHelp": "메시지에서 하이픈을 포함한 전체 코드를 복사하세요.",
890892 "verifyButton": "인증"
893893+ },
894894+ "delegation": {
895895+ "title": "계정 위임",
896896+ "controllers": "컨트롤러",
897897+ "controllersDescription": "컨트롤러는 귀하의 계정 관리자로서 행동할 수 있습니다. 귀하가 허용한 작업을 수행하고, 귀하를 대신하여 게시물을 생성하고, 저장소를 수정할 수 있습니다.",
898898+ "controlledAccounts": "관리 계정",
899899+ "controlledAccountsDescription": "귀하가 컨트롤러로 추가된 계정들입니다. 이 계정들에서 허용된 작업을 수행할 수 있습니다.",
900900+ "noControllers": "아직 컨트롤러가 없습니다",
901901+ "noControlledAccounts": "관리 계정이 없습니다",
902902+ "addController": "컨트롤러 추가",
903903+ "revokeAccess": "액세스 취소",
904904+ "revokeConfirm": "이 컨트롤러의 액세스를 취소하시겠습니까? 귀하의 계정에서 더 이상 작업을 수행할 수 없습니다.",
905905+ "handle": "핸들",
906906+ "handlePlaceholder": "@user.bsky.social",
907907+ "did": "DID",
908908+ "didPlaceholder": "did:plc:...",
909909+ "scopes": "권한 수준",
910910+ "scopeOwner": "소유자",
911911+ "scopeOwnerDesc": "전체 관리(모든 작업 수행 가능)",
912912+ "scopeAdmin": "관리자",
913913+ "scopeAdminDesc": "게시물, 앱 비밀번호, 설정 관리",
914914+ "scopeEditor": "편집자",
915915+ "scopeEditorDesc": "게시물, 좋아요, 팔로우 생성 및 관리",
916916+ "scopeViewer": "뷰어",
917917+ "scopeViewerDesc": "저장소 및 설정 읽기 전용 액세스",
918918+ "scopeCustom": "사용자 정의",
919919+ "scopeCustomDesc": "개별 권한 선택",
920920+ "grantedAt": "허용 일시",
921921+ "expiresAt": "만료",
922922+ "noExpiration": "무기한",
923923+ "actAs": "로 활동",
924924+ "auditLog": "감사 로그",
925925+ "auditLogTitle": "위임 감사 로그",
926926+ "backToControllers": "← 컨트롤러로 돌아가기",
927927+ "loading": "로딩 중...",
928928+ "noActivity": "아직 활동이 없습니다",
929929+ "actor": "액터",
930930+ "controller": "컨트롤러",
931931+ "account": "계정",
932932+ "details": "세부정보",
933933+ "actionGrantCreated": "권한 생성",
934934+ "actionGrantRevoked": "권한 취소",
935935+ "actionScopesModified": "권한 수정",
936936+ "actionTokenIssued": "토큰 발급",
937937+ "actionRepoWrite": "저장소 쓰기",
938938+ "actionBlobUpload": "Blob 업로드",
939939+ "actionAccountAction": "계정 작업",
940940+ "previous": "이전",
941941+ "next": "다음",
942942+ "showing": "{start}~{end} / {total}개",
943943+ "refresh": "새로고침",
944944+ "failedToLoadAuditLog": "감사 로그를 불러오지 못했습니다",
945945+ "addControllerTitle": "컨트롤러 추가",
946946+ "addControllerDescription": "이 계정에서 지정된 권한으로 작업할 수 있는 사용자를 추가합니다.",
947947+ "controllerIdentifier": "컨트롤러 핸들 또는 DID",
948948+ "selectScopes": "권한 수준 선택",
949949+ "add": "추가",
950950+ "adding": "추가 중...",
951951+ "cancel": "취소",
952952+ "accessLevel": "액세스 수준",
953953+ "addControllerButton": "+ 컨트롤러 추가",
954954+ "auditLogDesc": "모든 위임 활동 보기",
955955+ "cannotAddControllers": "다른 계정을 관리하고 있어 컨트롤러를 추가할 수 없습니다. 계정은 컨트롤러를 가지거나 다른 계정을 관리할 수 있지만 둘 다는 불가능합니다.",
956956+ "cannotControlAccounts": "이 계정에 컨트롤러가 있어 다른 계정을 관리할 수 없습니다. 계정은 컨트롤러를 가지거나 다른 계정을 관리할 수 있지만 둘 다는 불가능합니다.",
957957+ "controlledAccountsDesc": "귀하가 대신 작업할 수 있는 계정",
958958+ "controllerAdded": "컨트롤러가 추가되었습니다",
959959+ "controllerDid": "컨트롤러 DID",
960960+ "controllerRemoved": "컨트롤러가 제거되었습니다",
961961+ "controllersDesc": "귀하를 대신하여 작업할 수 있는 계정",
962962+ "createAccount": "계정 생성",
963963+ "createDelegatedAccount": "위임 계정 생성",
964964+ "createDelegatedAccountButton": "+ 위임 계정 생성",
965965+ "creating": "생성 중...",
966966+ "emailOptional": "이메일 (선택사항)",
967967+ "failedToAddController": "컨트롤러 추가에 실패했습니다",
968968+ "failedToCreateAccount": "위임 계정 생성에 실패했습니다",
969969+ "failedToRemoveController": "컨트롤러 제거에 실패했습니다",
970970+ "granted": "허용일",
971971+ "inactive": "비활성",
972972+ "remove": "제거",
973973+ "removeConfirm": "이 컨트롤러를 제거하시겠습니까?",
974974+ "viewAuditLog": "감사 로그 보기",
975975+ "yourAccessLevel": "귀하의 액세스 수준"
976976+ },
977977+ "actAs": {
978978+ "title": "로 활동",
979979+ "noAccountSpecified": "계정 DID가 지정되지 않았습니다",
980980+ "failedToVerify": "계정 액세스를 확인하지 못했습니다",
981981+ "noAccess": "이 계정에 대한 액세스 권한이 없습니다",
982982+ "failedToInitiate": "인증 시작에 실패했습니다",
983983+ "invalidResponse": "서버에서 잘못된 응답을 받았습니다",
984984+ "failedError": "실패: {error}",
985985+ "preparing": "위임 계정 로그인 준비 중...",
986986+ "backToControllers": "컨트롤러로 돌아가기"
987987+ },
988988+ "oauthDelegation": {
989989+ "loading": "로딩 중...",
990990+ "title": "위임 계정",
991991+ "isDelegated": "{handle}은(는) 위임 계정입니다.",
992992+ "enterControllerHandle": "이 계정에 액세스하려면 컨트롤러 계정으로 로그인하세요.",
993993+ "controllerHandle": "컨트롤러 핸들",
994994+ "handlePlaceholder": "handle.example.com",
995995+ "checking": "확인 중...",
996996+ "controllerNotFound": "계정을 찾을 수 없거나 이 위임 계정에 대한 액세스 권한이 없습니다",
997997+ "missingParams": "위임 매개변수가 없습니다",
998998+ "missingInfo": "필요한 정보가 없습니다",
999999+ "passkeyCancelled": "패스키 인증이 취소되었습니다",
10001000+ "passkeyFailed": "패스키 인증에 실패했습니다",
10011001+ "failedPasskeyStart": "패스키 로그인 시작에 실패했습니다",
10021002+ "authFailed": "인증에 실패했습니다",
10031003+ "unexpectedResponse": "서버에서 예기치 않은 응답을 받았습니다",
10041004+ "signInAsController": "컨트롤러로 로그인",
10051005+ "authenticateAs": "{controller}(으)로 인증하여 {delegated}를 대신합니다",
10061006+ "useDifferentController": "다른 컨트롤러 사용",
10071007+ "signInWithPasskey": "패스키로 로그인",
10081008+ "authenticating": "인증 중...",
10091009+ "usePasskey": "패스키 사용",
10101010+ "or": "또는",
10111011+ "password": "비밀번호",
10121012+ "enterPassword": "비밀번호 입력",
10131013+ "rememberDevice": "이 기기 기억하기",
10141014+ "signingIn": "로그인 중...",
10151015+ "signIn": "로그인",
10161016+ "goBack": "뒤로",
10171017+ "unableToLoad": "위임 정보를 로드할 수 없습니다"
10181018+ },
10191019+ "oauthConsent": {
10201020+ "delegatedAccess": "위임 액세스",
10211021+ "actingAs": "활동 계정",
10221022+ "controller": "컨트롤러",
10231023+ "accessLevel": "액세스 수준",
10241024+ "readOnlyAccess": "읽기 전용 액세스",
10251025+ "readOnlyDesc": "공개 정보만 볼 수 있습니다. 이 계정에 대한 쓰기 권한이 없습니다.",
10261026+ "permissionsLimited": "권한 제한됨",
10271027+ "permissionsLimitedDesc": "앱이 무엇을 요청하든 실제 권한은 {level} 액세스 수준으로 제한됩니다.",
10281028+ "viewerLimitedDesc": "뷰어로서 읽기 전용 액세스 권한만 있습니다. 이 앱은 이 계정에서 콘텐츠를 생성, 수정 또는 삭제할 수 없습니다.",
10291029+ "editorLimitedDesc": "편집자로서 콘텐츠를 생성하고 편집할 수 있지만 계정 설정이나 보안을 관리할 수 없습니다."
8911030 }
8921031}
+140-1
frontend/src/locales/sv.json
···66 "cancel": "Avbryt",
77 "back": "Tillbaka",
88 "done": "Klar",
99+ "continue": "Fortsätt",
910 "refresh": "Uppdatera",
1011 "create": "Skapa",
1112 "delete": "Radera",
···271272 "scopeFull": "Full åtkomst",
272273 "scopeReadOnly": "Endast läsning",
273274 "scopePostOnly": "Endast publicering",
274274- "scopeCustom": "Anpassad"
275275+ "scopeCustom": "Anpassad",
276276+ "byController": "Av controller"
275277 },
276278 "sessions": {
277279 "title": "Aktiva sessioner",
···888890 "codeLabel": "Verifieringskod",
889891 "codeHelp": "Kopiera hela koden från ditt meddelande, inklusive bindestreck.",
890892 "verifyButton": "Verifiera"
893893+ },
894894+ "delegation": {
895895+ "title": "Kontodelegering",
896896+ "controllers": "Kontrollanter",
897897+ "controllersDescription": "Kontrollanter kan agera som administratörer för ditt konto. De kan utföra åtgärder du tillåter, skapa inlägg för din räkning och modifiera din dataförvaring.",
898898+ "controlledAccounts": "Kontrollerade konton",
899899+ "controlledAccountsDescription": "Detta är konton där du har lagts till som kontrollant. Du kan utföra tillåtna åtgärder på dessa konton.",
900900+ "noControllers": "Inga kontrollanter ännu",
901901+ "noControlledAccounts": "Inga kontrollerade konton",
902902+ "addController": "Lägg till kontrollant",
903903+ "revokeAccess": "Återkalla åtkomst",
904904+ "revokeConfirm": "Återkalla denna kontrollants åtkomst? De kommer inte längre kunna utföra åtgärder på ditt konto.",
905905+ "handle": "Användarnamn",
906906+ "handlePlaceholder": "@user.bsky.social",
907907+ "did": "DID",
908908+ "didPlaceholder": "did:plc:...",
909909+ "scopes": "Behörighetsnivå",
910910+ "scopeOwner": "Ägare",
911911+ "scopeOwnerDesc": "Fullständig kontroll (kan utföra alla åtgärder)",
912912+ "scopeAdmin": "Administratör",
913913+ "scopeAdminDesc": "Hantera inlägg, applösenord, inställningar",
914914+ "scopeEditor": "Redaktör",
915915+ "scopeEditorDesc": "Skapa och hantera inlägg, gillningar, följningar",
916916+ "scopeViewer": "Läsare",
917917+ "scopeViewerDesc": "Endast läsåtkomst till dataförvaring och inställningar",
918918+ "scopeCustom": "Anpassad",
919919+ "scopeCustomDesc": "Välj individuella behörigheter",
920920+ "grantedAt": "Beviljad",
921921+ "expiresAt": "Upphör",
922922+ "noExpiration": "Ingen utgång",
923923+ "actAs": "Agera som",
924924+ "auditLog": "Granskningslogg",
925925+ "auditLogTitle": "Delegerings-granskningslogg",
926926+ "backToControllers": "← Tillbaka till kontrollanter",
927927+ "loading": "Laddar...",
928928+ "noActivity": "Ingen aktivitet ännu",
929929+ "actor": "Aktör",
930930+ "controller": "Kontrollant",
931931+ "account": "Konto",
932932+ "details": "Detaljer",
933933+ "actionGrantCreated": "Behörighet skapad",
934934+ "actionGrantRevoked": "Behörighet återkallad",
935935+ "actionScopesModified": "Behörigheter ändrade",
936936+ "actionTokenIssued": "Token utfärdad",
937937+ "actionRepoWrite": "Dataförvarsskrivning",
938938+ "actionBlobUpload": "Blob-uppladdning",
939939+ "actionAccountAction": "Kontoåtgärd",
940940+ "previous": "Föregående",
941941+ "next": "Nästa",
942942+ "showing": "{start}–{end} av {total}",
943943+ "refresh": "Uppdatera",
944944+ "failedToLoadAuditLog": "Kunde inte ladda granskningsloggen",
945945+ "addControllerTitle": "Lägg till kontrollant",
946946+ "addControllerDescription": "Lägg till en användare som kan utföra åtgärder på detta konto med specificerade behörigheter.",
947947+ "controllerIdentifier": "Kontrollantens användarnamn eller DID",
948948+ "selectScopes": "Välj behörighetsnivå",
949949+ "add": "Lägg till",
950950+ "adding": "Lägger till...",
951951+ "cancel": "Avbryt",
952952+ "accessLevel": "Åtkomstnivå",
953953+ "addControllerButton": "+ Lägg till kontrollant",
954954+ "auditLogDesc": "Visa all delegeringsaktivitet",
955955+ "cannotAddControllers": "Du kan inte lägga till kontrollanter eftersom detta konto kontrollerar andra konton. Ett konto kan antingen ha kontrollanter eller kontrollera andra konton, men inte båda.",
956956+ "cannotControlAccounts": "Du kan inte kontrollera andra konton eftersom detta konto har kontrollanter. Ett konto kan antingen ha kontrollanter eller kontrollera andra konton, men inte båda.",
957957+ "controlledAccountsDesc": "Konton du kan agera för",
958958+ "controllerAdded": "Kontrollant tillagd",
959959+ "controllerDid": "Kontrollant-DID",
960960+ "controllerRemoved": "Kontrollant borttagen",
961961+ "controllersDesc": "Konton som kan agera för dig",
962962+ "createAccount": "Skapa konto",
963963+ "createDelegatedAccount": "Skapa delegerat konto",
964964+ "createDelegatedAccountButton": "+ Skapa delegerat konto",
965965+ "creating": "Skapar...",
966966+ "emailOptional": "E-post (valfritt)",
967967+ "failedToAddController": "Kunde inte lägga till kontrollant",
968968+ "failedToCreateAccount": "Kunde inte skapa delegerat konto",
969969+ "failedToRemoveController": "Kunde inte ta bort kontrollant",
970970+ "granted": "Beviljad",
971971+ "inactive": "Inaktiv",
972972+ "remove": "Ta bort",
973973+ "removeConfirm": "Vill du ta bort denna kontrollant?",
974974+ "viewAuditLog": "Visa granskningslogg",
975975+ "yourAccessLevel": "Din åtkomstnivå"
976976+ },
977977+ "actAs": {
978978+ "title": "Agera som",
979979+ "noAccountSpecified": "Inget konto-DID angivet",
980980+ "failedToVerify": "Kunde inte verifiera kontoåtkomst",
981981+ "noAccess": "Du har inte åtkomst till detta konto",
982982+ "failedToInitiate": "Kunde inte initiera autentisering",
983983+ "invalidResponse": "Ogiltigt svar från servern",
984984+ "failedError": "Misslyckades: {error}",
985985+ "preparing": "Förbereder inloggning till delegerat konto...",
986986+ "backToControllers": "Tillbaka till kontrollanter"
987987+ },
988988+ "oauthDelegation": {
989989+ "loading": "Laddar...",
990990+ "title": "Delegerat konto",
991991+ "isDelegated": "{handle} är ett delegerat konto.",
992992+ "enterControllerHandle": "Logga in med ditt kontrollantkonto för att komma åt detta konto.",
993993+ "controllerHandle": "Kontrollantens användarnamn",
994994+ "handlePlaceholder": "handle.example.com",
995995+ "checking": "Kontrollerar...",
996996+ "controllerNotFound": "Kontot hittades inte eller så har du inte åtkomst till detta delegerade konto",
997997+ "missingParams": "Delegeringsparametrar saknas",
998998+ "missingInfo": "Nödvändig information saknas",
999999+ "passkeyCancelled": "Nyckelautentisering avbröts",
10001000+ "passkeyFailed": "Nyckelautentisering misslyckades",
10011001+ "failedPasskeyStart": "Kunde inte starta nyckelinloggning",
10021002+ "authFailed": "Autentisering misslyckades",
10031003+ "unexpectedResponse": "Oväntat svar från servern",
10041004+ "signInAsController": "Logga in som kontrollant",
10051005+ "authenticateAs": "Autentisera som {controller} för att agera på uppdrag av {delegated}",
10061006+ "useDifferentController": "Använd en annan kontrollant",
10071007+ "signInWithPasskey": "Logga in med nyckel",
10081008+ "authenticating": "Autentiserar...",
10091009+ "usePasskey": "Använd nyckel",
10101010+ "or": "eller",
10111011+ "password": "Lösenord",
10121012+ "enterPassword": "Ange lösenord",
10131013+ "rememberDevice": "Kom ihåg denna enhet",
10141014+ "signingIn": "Loggar in...",
10151015+ "signIn": "Logga in",
10161016+ "goBack": "Gå tillbaka",
10171017+ "unableToLoad": "Kunde inte ladda delegeringsinformation"
10181018+ },
10191019+ "oauthConsent": {
10201020+ "delegatedAccess": "Delegerad åtkomst",
10211021+ "actingAs": "Agerar som",
10221022+ "controller": "Kontrollant",
10231023+ "accessLevel": "Åtkomstnivå",
10241024+ "readOnlyAccess": "Endast läsåtkomst",
10251025+ "readOnlyDesc": "Visa endast offentlig information. Ingen skrivåtkomst till detta konto.",
10261026+ "permissionsLimited": "Behörigheter begränsade",
10271027+ "permissionsLimitedDesc": "Dina faktiska behörigheter begränsas till din {level}-åtkomstnivå, oavsett vad appen begär.",
10281028+ "viewerLimitedDesc": "Som visare har du endast läsåtkomst. Denna app kommer inte att kunna skapa, uppdatera eller ta bort innehåll på detta konto.",
10291029+ "editorLimitedDesc": "Som redigerare kan du skapa och redigera innehåll men kan inte hantera kontoinställningar eller säkerhet."
8911030 }
8921031}
···178178 <h3>App passwords with guardrails</h3>
179179 <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>
180180 </div>
181181+182182+ <div class="feature">
183183+ <h3>Delegate without sharing passwords</h3>
184184+ <p>Let team members or tools manage your account with specific permission levels. They authenticate with their own credentials, you see everything they do in an audit log.</p>
185185+ </div>
181186 </div>
182187183188 <h2>Everything in one place</h2>
···11+CREATE TYPE account_type AS ENUM ('personal', 'delegated');
22+33+ALTER TABLE users ADD COLUMN account_type account_type NOT NULL DEFAULT 'personal';
44+55+CREATE TYPE delegation_action_type AS ENUM (
66+ 'grant_created',
77+ 'grant_revoked',
88+ 'scopes_modified',
99+ 'token_issued',
1010+ 'repo_write',
1111+ 'blob_upload',
1212+ 'account_action'
1313+);
1414+1515+CREATE TABLE account_delegations (
1616+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
1717+ delegated_did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
1818+ controller_did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
1919+ granted_scopes TEXT NOT NULL,
2020+ granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
2121+ granted_by TEXT NOT NULL REFERENCES users(did),
2222+ revoked_at TIMESTAMPTZ,
2323+ revoked_by TEXT REFERENCES users(did),
2424+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
2525+);
2626+2727+CREATE UNIQUE INDEX unique_active_delegation ON account_delegations(delegated_did, controller_did)
2828+ WHERE revoked_at IS NULL;
2929+CREATE INDEX idx_delegations_delegated ON account_delegations(delegated_did) WHERE revoked_at IS NULL;
3030+CREATE INDEX idx_delegations_controller ON account_delegations(controller_did) WHERE revoked_at IS NULL;
3131+3232+CREATE TABLE delegation_audit_log (
3333+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
3434+ delegated_did TEXT NOT NULL,
3535+ actor_did TEXT NOT NULL,
3636+ controller_did TEXT,
3737+ action_type delegation_action_type NOT NULL,
3838+ action_details JSONB,
3939+ ip_address TEXT,
4040+ user_agent TEXT,
4141+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
4242+);
4343+4444+CREATE INDEX idx_delegation_audit_delegated ON delegation_audit_log(delegated_did, created_at DESC);
4545+CREATE INDEX idx_delegation_audit_controller ON delegation_audit_log(controller_did, created_at DESC) WHERE controller_did IS NOT NULL;
4646+4747+ALTER TABLE oauth_authorization_request ADD COLUMN controller_did TEXT;
4848+4949+ALTER TABLE oauth_token ADD COLUMN controller_did TEXT;
5050+CREATE INDEX idx_oauth_token_controller ON oauth_token(controller_did) WHERE controller_did IS NOT NULL;
5151+5252+ALTER TABLE app_passwords ADD COLUMN created_by_controller_did TEXT REFERENCES users(did) ON DELETE SET NULL;
5353+CREATE INDEX idx_app_passwords_controller ON app_passwords(created_by_controller_did) WHERE created_by_controller_did IS NOT NULL;
5454+5555+ALTER TABLE session_tokens ADD COLUMN controller_did TEXT;
···11pub mod authorize;
22+pub mod delegation;
23pub mod metadata;
34pub mod par;
45pub mod token;
5667pub use authorize::*;
88+pub use delegation::*;
79pub use metadata::*;
810pub use par::*;
911pub use token::*;
+5-2
src/oauth/endpoints/par.rs
···5858 serde_json::from_slice(&body)
5959 .map_err(|e| OAuthError::InvalidRequest(format!("Invalid JSON: {}", e)))?
6060 } else if content_type.starts_with("application/x-www-form-urlencoded") {
6161- serde_urlencoded::from_bytes(&body)
6262- .map_err(|e| OAuthError::InvalidRequest(format!("Invalid form data: {}", e)))?
6161+ let parsed: ParRequest = serde_urlencoded::from_bytes(&body)
6262+ .map_err(|e| OAuthError::InvalidRequest(format!("Invalid form data: {}", e)))?;
6363+ tracing::info!(login_hint = ?parsed.login_hint, "PAR request received (form)");
6464+ parsed
6365 } else {
6466 return Err(OAuthError::InvalidRequest(
6567 "Content-Type must be application/json or application/x-www-form-urlencoded"
···128130 did: None,
129131 device_id: None,
130132 code: None,
133133+ controller_did: None,
131134 };
132135 db::create_authorization_request(&state.db, &request_id.0, &request_data).await?;
133136 tokio::spawn({
+30-7
src/oauth/endpoints/token/grants.rs
···11-use super::helpers::{create_access_token, verify_pkce};
11+use super::helpers::{create_access_token_with_delegation, verify_pkce};
22use super::types::{TokenRequest, TokenResponse};
33use crate::config::AuthConfig;
44+use crate::delegation;
45use crate::oauth::{
56 ClientAuth, OAuthError, RefreshToken, TokenData, TokenId,
67 client::{ClientMetadataCache, verify_client_auth},
···106107 let token_id = TokenId::generate();
107108 let refresh_token = RefreshToken::generate();
108109 let now = Utc::now();
109109- let access_token = create_access_token(
110110+111111+ let (final_scope, controller_did) = if let Some(ref controller) = auth_request.controller_did {
112112+ let grant = delegation::get_delegation(&state.db, &did, controller)
113113+ .await
114114+ .ok()
115115+ .flatten();
116116+ let granted_scopes = grant.map(|g| g.granted_scopes).unwrap_or_default();
117117+ let requested = auth_request
118118+ .parameters
119119+ .scope
120120+ .as_deref()
121121+ .unwrap_or("atproto");
122122+ let intersected = delegation::intersect_scopes(requested, &granted_scopes);
123123+ (Some(intersected), Some(controller.clone()))
124124+ } else {
125125+ (auth_request.parameters.scope.clone(), None)
126126+ };
127127+128128+ let access_token = create_access_token_with_delegation(
110129 &token_id.0,
111130 &did,
112131 dpop_jkt.as_deref(),
113113- auth_request.parameters.scope.as_deref(),
132132+ final_scope.as_deref(),
133133+ controller_did.as_deref(),
114134 )?;
115135 let stored_client_auth = auth_request.client_auth.unwrap_or(ClientAuth::None);
116136 let refresh_expiry_days = if matches!(stored_client_auth, ClientAuth::None) {
···131151 details: None,
132152 code: None,
133153 current_refresh_token: Some(refresh_token.0.clone()),
134134- scope: auth_request.parameters.scope.clone(),
154154+ scope: final_scope.clone(),
155155+ controller_did: controller_did.clone(),
135156 };
136157 db::create_token(&state.db, &token_data).await?;
137158 tokio::spawn({
···154175 token_type: if dpop_jkt.is_some() { "DPoP" } else { "Bearer" }.to_string(),
155176 expires_in: ACCESS_TOKEN_EXPIRY_SECONDS as u64,
156177 refresh_token: Some(refresh_token.0),
157157- scope: auth_request.parameters.scope,
178178+ scope: final_scope,
158179 sub: Some(did),
159180 }),
160181 ))
···183204 "Refresh token reuse within grace period, returning existing tokens"
184205 );
185206 let dpop_jkt = token_data.parameters.dpop_jkt.as_deref();
186186- let access_token = create_access_token(
207207+ let access_token = create_access_token_with_delegation(
187208 &token_data.token_id,
188209 &token_data.did,
189210 dpop_jkt,
190211 token_data.scope.as_deref(),
212212+ token_data.controller_did.as_deref(),
191213 )?;
192214 let mut response_headers = HeaderMap::new();
193215 let config = AuthConfig::get();
···282304 new_expires_at = %new_expires_at,
283305 "Refresh token rotated successfully"
284306 );
285285- let access_token = create_access_token(
307307+ let access_token = create_access_token_with_delegation(
286308 &new_token_id.0,
287309 &token_data.did,
288310 dpop_jkt.as_deref(),
289311 token_data.scope.as_deref(),
312312+ token_data.controller_did.as_deref(),
290313 )?;
291314 let mut response_headers = HeaderMap::new();
292315 let config = AuthConfig::get();