Implement OAuth2 PKCE auth, discovery, and mobile API client

Journal server (Phase 1.4 + 1.5):
- Add oauth_clients, oauth_codes, oauth_tokens tables to journal schema
- Implement GET /oauth/authorize with PKCE flow and login redirect
- Implement POST /oauth/token (authorization_code + refresh_token grants)
- Add validateBearerToken() + getAuthenticatedUser() middleware
- Seed trails-cool-mobile as trusted OAuth client on server startup
- Add GET /.well-known/trails-cool discovery endpoint
- Add returnTo support to login page and magic link verify
- Add @trails-cool/api workspace dependency to journal

Mobile app (Phase 1.5 + 1.6):
- Login screen with server URL input and discovery validation
- OAuth2 PKCE login via expo-web-browser with expo-crypto for Hermes
- Token storage in expo-secure-store with auto-refresh on 401
- API client with bearer token injection and typed errors
- Server URL persistence with localhost default in dev mode
- API version compatibility check on app foreground
- Log out + switch server on Profile tab
- iOS ATS exception for local networking

Tests:
- PKCE crypto verification, OAuthError, token generation
- Discovery endpoint response shape
- API version semver compatibility
- API client error types

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-13 00:41:40 +02:00
parent 998db213d6
commit 1ae406a8aa
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
25 changed files with 1402 additions and 134 deletions

150
pnpm-lock.yaml generated
View file

@ -206,6 +206,9 @@ importers:
'@simplewebauthn/server':
specifier: ^13.3.0
version: 13.3.0
'@trails-cool/api':
specifier: workspace:*
version: link:../../packages/api
'@trails-cool/db':
specifier: workspace:*
version: link:../../packages/db
@ -306,12 +309,21 @@ importers:
expo:
specifier: ~55.0.14
version: 55.0.14(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@5.0.4(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0)))(expo-router@55.0.12)(react-dom@19.2.5(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo-crypto:
specifier: ~55.0.14
version: 55.0.14(expo@55.0.14)
expo-router:
specifier: ~55.0.12
version: 55.0.12(@expo/log-box@55.0.10)(@expo/metro-runtime@5.0.4(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0)))(@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@25.5.2))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react-test-renderer@19.2.0(react@19.2.0))(react@19.2.0))(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(expo-constants@55.0.13)(expo-font@55.0.6)(expo-linking@7.0.5)(expo@55.0.14)(react-dom@19.2.5(react@19.2.0))(react-native-safe-area-context@5.6.2(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-screens@4.23.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
expo-secure-store:
specifier: ~55.0.13
version: 55.0.13(expo@55.0.14)
expo-status-bar:
specifier: ~55.0.5
version: 55.0.5(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
expo-web-browser:
specifier: ~55.0.14
version: 55.0.14(expo@55.0.14)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))
react:
specifier: 19.2.0
version: 19.2.0
@ -329,8 +341,8 @@ importers:
specifier: ^13.3.3
version: 13.3.3(jest@29.7.0(@types/node@25.5.2))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react-test-renderer@19.2.0(react@19.2.0))(react@19.2.0)
'@types/jest':
specifier: ^30.0.0
version: 30.0.0
specifier: ^29.5.14
version: 29.5.14
'@types/react':
specifier: ~19.2.14
version: 19.2.14
@ -1886,10 +1898,6 @@ packages:
resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
'@jest/expect-utils@30.3.0':
resolution: {integrity: sha512-j0+W5iQQ8hBh7tHZkTQv3q2Fh/M7Je72cIsYqC4OaktgtO7v1So9UTjp6uPBHIaB6beoF/RRsCgMJKvti0wADA==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
'@jest/expect@29.7.0':
resolution: {integrity: sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@ -1906,10 +1914,6 @@ packages:
resolution: {integrity: sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
'@jest/pattern@30.0.1':
resolution: {integrity: sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
'@jest/reporters@29.7.0':
resolution: {integrity: sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@ -1947,10 +1951,6 @@ packages:
resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
'@jest/types@30.3.0':
resolution: {integrity: sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@ -3314,8 +3314,8 @@ packages:
'@types/istanbul-reports@3.0.4':
resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==}
'@types/jest@30.0.0':
resolution: {integrity: sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==}
'@types/jest@29.5.14':
resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==}
'@types/jsdom@20.0.1':
resolution: {integrity: sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==}
@ -3844,10 +3844,6 @@ packages:
resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==}
engines: {node: '>=8'}
ci-info@4.4.0:
resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==}
engines: {node: '>=8'}
cjs-module-lexer@1.4.3:
resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==}
@ -4442,10 +4438,6 @@ packages:
resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
expect@30.3.0:
resolution: {integrity: sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
expo-asset@55.0.14:
resolution: {integrity: sha512-8jeWHW39/UOQytGoXXFIrpE+DhK72RhMu09iuTxYuGluqGzGgs+DgcaP9jTvCPwkAXxSfWZdsTttuKXE5nDUCQ==}
peerDependencies:
@ -4465,6 +4457,11 @@ packages:
expo: '*'
react-native: '*'
expo-crypto@55.0.14:
resolution: {integrity: sha512-TfAADBGZNNv9OOmdKFJCz54wDj87ufxtzQNSY+Roycpm8e5tuCnDIL7EjqUOmNTGH99Jj8ftPGFt4KGG2Ii2fg==}
peerDependencies:
expo: '*'
expo-file-system@55.0.16:
resolution: {integrity: sha512-EetQ/zVFK07Vmz4Yke0fvoES4xVwScTdd0PMoLekuMX7puE4op75pNnEdh1M0AeWzkqLrBoZIaU2ynSrKN5VZg==}
peerDependencies:
@ -4557,6 +4554,11 @@ packages:
react-server-dom-webpack:
optional: true
expo-secure-store@55.0.13:
resolution: {integrity: sha512-I6r0JNO1Fd4o0Gu7Ixiic7s89lqgdUHq17uBH9y1f/AntoyKn71TdtYJH82RgfsBbu5qNVzrwImmvlANyOlITQ==}
peerDependencies:
expo: '*'
expo-server@55.0.7:
resolution: {integrity: sha512-Cc1btFyPsD9P4DT2xd1pG/uR96TLVMx0W+dPm9Gjk1uDV9xuzvMcUsY7nf9bt4U5pGyWWkCXmPJcKwWfdl51Pw==}
engines: {node: '>=20.16.0'}
@ -4575,6 +4577,12 @@ packages:
react: '*'
react-native: '*'
expo-web-browser@55.0.14:
resolution: {integrity: sha512-bTDkBSQBnrlnYcM7Aak72AOvJuvdgA3M8p//Lazrm0Nfa77T9cRXzQ6KhLrB08V39n1+00d1dvuTWznJslkmdg==}
peerDependencies:
expo: '*'
react-native: '*'
expo@55.0.14:
resolution: {integrity: sha512-MqFdpyE3z5MZqb6Q9v6RqXzbRDbd0RMlGdVLSA/ObX6vgHhzCDIjeb+Uwao9P7R0uebsC4b126jBWxuhMmJHZQ==}
hasBin: true
@ -5117,18 +5125,10 @@ packages:
resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
jest-message-util@30.3.0:
resolution: {integrity: sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
jest-mock@29.7.0:
resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
jest-mock@30.3.0:
resolution: {integrity: sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
jest-pnp-resolver@1.2.3:
resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==}
engines: {node: '>=6'}
@ -5142,10 +5142,6 @@ packages:
resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
jest-regex-util@30.0.1:
resolution: {integrity: sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
jest-resolve-dependencies@29.7.0:
resolution: {integrity: sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@ -5170,10 +5166,6 @@ packages:
resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
jest-util@30.3.0:
resolution: {integrity: sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
jest-validate@29.7.0:
resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@ -8659,10 +8651,6 @@ snapshots:
dependencies:
jest-get-type: 29.6.3
'@jest/expect-utils@30.3.0':
dependencies:
'@jest/get-type': 30.1.0
'@jest/expect@29.7.0':
dependencies:
expect: 29.7.0
@ -8690,11 +8678,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@jest/pattern@30.0.1':
dependencies:
'@types/node': 25.5.2
jest-regex-util: 30.0.1
'@jest/reporters@29.7.0':
dependencies:
'@bcoe/v8-coverage': 0.2.3
@ -8781,16 +8764,6 @@ snapshots:
'@types/yargs': 17.0.35
chalk: 4.1.2
'@jest/types@30.3.0':
dependencies:
'@jest/pattern': 30.0.1
'@jest/schemas': 30.0.5
'@types/istanbul-lib-coverage': 2.0.6
'@types/istanbul-reports': 3.0.4
'@types/node': 25.5.2
'@types/yargs': 17.0.35
chalk: 4.1.2
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@ -10319,10 +10292,10 @@ snapshots:
dependencies:
'@types/istanbul-lib-report': 3.0.3
'@types/jest@30.0.0':
'@types/jest@29.5.14':
dependencies:
expect: 30.3.0
pretty-format: 30.3.0
expect: 29.7.0
pretty-format: 29.7.0
'@types/jsdom@20.0.1':
dependencies:
@ -10957,8 +10930,6 @@ snapshots:
ci-info@3.9.0: {}
ci-info@4.4.0: {}
cjs-module-lexer@1.4.3: {}
cjs-module-lexer@2.2.0: {}
@ -11509,15 +11480,6 @@ snapshots:
jest-message-util: 29.7.0
jest-util: 29.7.0
expect@30.3.0:
dependencies:
'@jest/expect-utils': 30.3.0
'@jest/get-type': 30.1.0
jest-matcher-utils: 30.3.0
jest-message-util: 30.3.0
jest-mock: 30.3.0
jest-util: 30.3.0
expo-asset@55.0.14(expo@55.0.14)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3):
dependencies:
'@expo/image-utils': 0.8.13(typescript@5.9.3)
@ -11548,6 +11510,10 @@ snapshots:
- supports-color
- typescript
expo-crypto@55.0.14(expo@55.0.14):
dependencies:
expo: 55.0.14(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@5.0.4(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0)))(expo-router@55.0.12)(react-dom@19.2.5(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo-file-system@55.0.16(expo@55.0.14)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0)):
dependencies:
expo: 55.0.14(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@5.0.4(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0)))(expo-router@55.0.12)(react-dom@19.2.5(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
@ -11650,6 +11616,10 @@ snapshots:
- expo-font
- supports-color
expo-secure-store@55.0.13(expo@55.0.14):
dependencies:
expo: 55.0.14(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@5.0.4(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0)))(expo-router@55.0.12)(react-dom@19.2.5(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
expo-server@55.0.7: {}
expo-status-bar@55.0.5(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0):
@ -11667,6 +11637,11 @@ snapshots:
react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0)
sf-symbols-typescript: 2.2.0
expo-web-browser@55.0.14(expo@55.0.14)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0)):
dependencies:
expo: 55.0.14(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@5.0.4(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0)))(expo-router@55.0.12)(react-dom@19.2.5(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0)
expo@55.0.14(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@5.0.4(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0)))(expo-router@55.0.12)(react-dom@19.2.5(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3):
dependencies:
'@babel/runtime': 7.29.2
@ -12381,38 +12356,18 @@ snapshots:
slash: 3.0.0
stack-utils: 2.0.6
jest-message-util@30.3.0:
dependencies:
'@babel/code-frame': 7.29.0
'@jest/types': 30.3.0
'@types/stack-utils': 2.0.3
chalk: 4.1.2
graceful-fs: 4.2.11
picomatch: 4.0.4
pretty-format: 30.3.0
slash: 3.0.0
stack-utils: 2.0.6
jest-mock@29.7.0:
dependencies:
'@jest/types': 29.6.3
'@types/node': 25.5.2
jest-util: 29.7.0
jest-mock@30.3.0:
dependencies:
'@jest/types': 30.3.0
'@types/node': 25.5.2
jest-util: 30.3.0
jest-pnp-resolver@1.2.3(jest-resolve@29.7.0):
optionalDependencies:
jest-resolve: 29.7.0
jest-regex-util@29.6.3: {}
jest-regex-util@30.0.1: {}
jest-resolve-dependencies@29.7.0:
dependencies:
jest-regex-util: 29.6.3
@ -12519,15 +12474,6 @@ snapshots:
graceful-fs: 4.2.11
picomatch: 2.3.2
jest-util@30.3.0:
dependencies:
'@jest/types': 30.3.0
'@types/node': 25.5.2
chalk: 4.1.2
ci-info: 4.4.0
graceful-fs: 4.2.11
picomatch: 4.0.4
jest-validate@29.7.0:
dependencies:
'@jest/types': 29.6.3