diff --git a/.gitignore b/.gitignore index 66ef5546..28a38381 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ build-* *.heap /api oom +*.tsbuildinfo diff --git a/Makefile b/Makefile index 3408f285..f7d8d95f 100644 --- a/Makefile +++ b/Makefile @@ -112,13 +112,15 @@ js-lexicons: && sed -i.bak 's/AppBskyGraphBlock\.Main/AppBskyGraphBlock\.Record/' $$(find ./js/app/lexicons/types/place/stream -type f) \ && sed -i.bak 's/PlaceStreamChatProfile\.Main/PlaceStreamChatProfile\.Record/' $$(find ./js/app/lexicons/types/place/stream -type f) \ && sed -i.bak "s/import\ \*\ as\ AppBskyFeedDefs\ from\ '.\/defs'/import \{ AppBskyFeedDefs } from '@atproto\/api'/" $$(find ./js/app/lexicons/types -type f) \ + && sed -i.bak "s/import\ \*\ as\ AppBskyActorDefs\ from\ '.\/defs'/import \{ AppBskyActorDefs } from '@atproto\/api'/" $$(find ./js/app/lexicons -type f) \ && find . | grep bak$$ | xargs rm .PHONY: md-lexicons md-lexicons: yarn exec lexmd \ lexicons/place/stream \ - js/docs/src/content/docs/lex-reference + js/docs/src/content/docs/lex-reference \ + && $(MAKE) fix .PHONY: lexgen lexgen: @@ -127,23 +129,17 @@ lexgen: .PHONY: lexgen-types lexgen-types: - go run github.com/bluesky-social/indigo/cmd/lexgen --package streamplace \ - --types-import place.stream:stream.place/streamplace/pkg/streamplace \ - -outdir ./pkg/streamplace \ - --prefix place.stream \ + go run github.com/bluesky-social/indigo/cmd/lexgen \ + -outdir ./pkg/spxrpc \ --build-file util/lexgen-types.json \ + --external-lexicons subprojects/atproto/lexicons \ lexicons/place/stream \ ./subprojects/atproto/lexicons -.PHONY: ci-lexicons -ci-lexicons: - $(MAKE) lexicons \ - && if ! git diff --exit-code >/dev/null; then echo "lexicons are out of date, run 'make lexicons' to fix"; exit 1; fi - .PHONY: lexgen-server lexgen-server: - mkdir -p ./pkg/spxrpc - go run github.com/bluesky-social/indigo/cmd/lexgen --package spxrpc \ + mkdir -p ./pkg/spxrpc \ + && go run github.com/bluesky-social/indigo/cmd/lexgen \ --gen-server \ --types-import place.stream:stream.place/streamplace/pkg/streamplace \ --types-import app.bsky:github.com/bluesky-social/indigo/api/bsky \ @@ -151,10 +147,17 @@ lexgen-server: --types-import chat.bsky:github.com/bluesky-social/indigo/api/chat \ --types-import tools.ozone:github.com/bluesky-social/indigo/api/ozone \ -outdir ./pkg/spxrpc \ - --prefix place.stream \ - --build-file util/lexgen-server.json \ + --build-file util/lexgen-types.json \ + --external-lexicons subprojects/atproto/lexicons \ + --package spxrpc \ lexicons/place/stream \ - lexicons/app/bsky + lexicons/app/bsky \ + lexicons/com/atproto + +.PHONY: ci-lexicons +ci-lexicons: + $(MAKE) lexicons \ + && if ! git diff --exit-code >/dev/null; then echo "lexicons are out of date, run 'make lexicons' to fix"; exit 1; fi .PHONY: test test: diff --git a/go.mod b/go.mod index ec4f8c7f..add53cbc 100644 --- a/go.mod +++ b/go.mod @@ -8,27 +8,32 @@ replace github.com/ThalesGroup/crypto11 => github.com/aquareum-tv/crypto11 v0.0. replace github.com/gocql/gocql => github.com/scylladb/gocql v1.14.4 +replace github.com/AxisCommunications/go-dpop => github.com/streamplace/go-dpop v0.0.0-20250510031900-c897158a8ad4 + +replace github.com/haileyok/atproto-oauth-golang => github.com/streamplace/atproto-oauth-golang v0.0.0-20250512021024-291d7209d3ab + require ( firebase.google.com/go/v4 v4.14.1 git.stream.place/streamplace/c2pa-go v0.7.0 github.com/99designs/gqlgen v0.17.64 + github.com/AxisCommunications/go-dpop v1.1.2 github.com/NYTimes/gziphandler v1.1.1 github.com/ThalesGroup/crypto11 v0.0.0-00010101000000-000000000000 github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d - github.com/bluesky-social/indigo v0.0.0-20250301025210-a4e0cc37e188 + github.com/bluesky-social/indigo v0.0.0-20250512184841-3edc6e261feb github.com/decred/dcrd/dcrec/secp256k1 v1.0.4 github.com/dunglas/httpsfv v1.0.2 github.com/ethereum/go-ethereum v1.14.7 github.com/go-git/go-git/v5 v5.12.0 github.com/go-gst/go-glib v1.4.0 github.com/go-gst/go-gst v1.4.0 + github.com/golang-jwt/jwt/v5 v5.2.1 github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 github.com/golang/glog v1.2.4 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 + github.com/haileyok/atproto-oauth-golang v0.0.2 github.com/ipfs/go-cid v0.4.1 - github.com/ipfs/go-datastore v0.6.0 - github.com/ipfs/go-ipfs-blockstore v1.3.1 github.com/johncgriffin/overflow v0.0.0-20211019200055-46fa312c352c github.com/julienschmidt/httprouter v1.3.0 github.com/labstack/echo/v4 v4.13.3 @@ -52,12 +57,14 @@ require ( go.opentelemetry.io/otel v1.35.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 go.opentelemetry.io/otel/sdk v1.35.0 + go.opentelemetry.io/otel/trace v1.35.0 go.uber.org/goleak v1.3.0 golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 golang.org/x/image v0.22.0 golang.org/x/net v0.35.0 golang.org/x/sync v0.11.0 golang.org/x/term v0.29.0 + golang.org/x/time v0.8.0 golang.org/x/tools v0.25.0 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 google.golang.org/api v0.189.0 @@ -130,7 +137,7 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/goccy/go-json v0.10.2 // indirect - github.com/gocql/gocql v0.0.0-00010101000000-000000000000 // indirect + github.com/gocql/gocql v1.7.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect @@ -153,6 +160,8 @@ require ( github.com/ipfs/bbloom v0.0.4 // indirect github.com/ipfs/go-block-format v0.2.0 // indirect github.com/ipfs/go-blockservice v0.5.2 // indirect + github.com/ipfs/go-datastore v0.6.0 // indirect + github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect github.com/ipfs/go-ipfs-ds-help v1.1.1 // indirect github.com/ipfs/go-ipfs-exchange-interface v0.2.1 // indirect github.com/ipfs/go-ipfs-util v0.0.3 // indirect @@ -252,7 +261,6 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect go.opentelemetry.io/otel/metric v1.35.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.35.0 // indirect - go.opentelemetry.io/otel/trace v1.35.0 // indirect go.opentelemetry.io/proto/otlp v1.5.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect @@ -261,7 +269,6 @@ require ( golang.org/x/mod v0.21.0 // indirect golang.org/x/oauth2 v0.26.0 // indirect golang.org/x/text v0.22.0 // indirect - golang.org/x/time v0.8.0 // indirect google.golang.org/appengine/v2 v2.0.2 // indirect google.golang.org/genproto v0.0.0-20240722135656-d784300faade // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect diff --git a/go.sum b/go.sum index c3ad5c1f..c43f2917 100644 --- a/go.sum +++ b/go.sum @@ -74,6 +74,8 @@ github.com/bits-and-blooms/bitset v1.10.0 h1:ePXTeiPEazB5+opbv5fr8umg2R/1NlzgDsy github.com/bits-and-blooms/bitset v1.10.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/bluesky-social/indigo v0.0.0-20250301025210-a4e0cc37e188 h1:1sQaG37xk08/rpmdhrmMkfQWF9kZbnfHm9Zav3bbSMk= github.com/bluesky-social/indigo v0.0.0-20250301025210-a4e0cc37e188/go.mod h1:NVBwZvbBSa93kfyweAmKwOLYawdVHdwZ9s+GZtBBVLA= +github.com/bluesky-social/indigo v0.0.0-20250512184841-3edc6e261feb h1:qfkNGUq//RzFBRFNBVwRKcFwyXS+1jQ5VnLW9Jfh6Vc= +github.com/bluesky-social/indigo v0.0.0-20250512184841-3edc6e261feb/go.mod h1:ovyxp8AMO1Hoe838vMJUbqHTZaAR8ABM3g3TXu+A5Ng= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= github.com/btcsuite/btcd/btcec/v2 v2.2.0 h1:fzn1qaOt32TuLjFlkzYSsBC35Q3KUjT1SwPxiMSCF5k= @@ -208,6 +210,8 @@ github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69 github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= @@ -387,6 +391,8 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/johncgriffin/overflow v0.0.0-20211019200055-46fa312c352c h1:2n/HCxBM7oa5PNCPKIhV26EtJkaPXFfcVojPAT3ujTU= github.com/johncgriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:B9OPZOhZ3FIi6bu54lAgCMzXLh11Z7ilr3rOr/ClP+E= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/jstemmer/go-junit-report v1.0.0 h1:8X1gzZpR+nVQLAht+L/foqOeX2l9DTZoaIPbEQHxsds= github.com/jstemmer/go-junit-report v1.0.0/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= @@ -612,6 +618,10 @@ github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/streamplace/atproto-oauth-golang v0.0.0-20250512021024-291d7209d3ab h1:cXikoKjZxnUiRQ+IY4+3XU4eJL1g0JJ5TzRA2keUNBg= +github.com/streamplace/atproto-oauth-golang v0.0.0-20250512021024-291d7209d3ab/go.mod h1:jcZ4GCjo5I5RuE/RsAXg1/b6udw7R4W+2rb/cGyTDK8= +github.com/streamplace/go-dpop v0.0.0-20250510031900-c897158a8ad4 h1:L1fS4HJSaAyNnkwfuZubgfeZy8rkWmA0cMtH5Z0HqNc= +github.com/streamplace/go-dpop v0.0.0-20250510031900-c897158a8ad4/go.mod h1:bGUXY9Wd4mnd+XUrOYZr358J2f6z9QO/dLhL1SsiD+0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= diff --git a/js/app/.env.development b/js/app/.env.development index f8372d14..9d84e273 100644 --- a/js/app/.env.development +++ b/js/app/.env.development @@ -1,3 +1,3 @@ EXPO_PUBLIC_STREAMPLACE_URL=http://127.0.0.1:38080 -EXPO_PUBLIC_WEB_TRY_LOCAL=false +EXPO_PUBLIC_WEB_TRY_LOCAL=true EXPO_USE_METRO_WORKSPACE_ROOT=1 \ No newline at end of file diff --git a/js/app/components/login/login.tsx b/js/app/components/login/login.tsx index 09d73a74..1b6a46d8 100644 --- a/js/app/components/login/login.tsx +++ b/js/app/components/login/login.tsx @@ -1,16 +1,31 @@ +import { AtpBaseClient } from "lexicons"; import NameColorPicker from "components/name-color-picker/name-color-picker"; import { login, logout, + selectIsReady, selectLogin, selectPDS, selectUserProfile, setPDS, } from "features/bluesky/blueskySlice"; -import { useState } from "react"; -import { Keyboard } from "react-native"; +import { useEffect, useState } from "react"; +import { Keyboard, KeyboardAvoidingView } from "react-native"; import { useAppDispatch, useAppSelector } from "store/hooks"; -import { Button, Form, H3, Input, Sheet, Spinner, Text, View } from "tamagui"; +import { + Button, + Form, + H3, + H5, + Input, + Sheet, + Spinner, + Text, + View, +} from "tamagui"; +import useStreamplaceNode from "hooks/useStreamplaceNode"; +import Loading from "components/loading/loading"; +import { useToastController } from "@tamagui/toast"; export default function Login() { const dispatch = useAppDispatch(); @@ -18,11 +33,30 @@ export default function Login() { const pds = useAppSelector(selectPDS); const loginState = useAppSelector(selectLogin); const [open, setOpen] = useState(false); + const [handle, setHandle] = useState(""); + const isReady = useAppSelector(selectIsReady); + const toast = useToastController(); const onOpenChange = (open: boolean) => { setOpen(open); Keyboard.dismiss(); }; + useEffect(() => { + if (loginState?.error) { + toast.show("Login error", { + message: loginState.error, + }); + } + }, [loginState?.error]); + + if (!isReady) { + return ( + + + + ); + } + if (userProfile) { return ( @@ -51,109 +85,52 @@ export default function Login() { } return ( - - - {/* {error} */} - - - - ); -} - -export function ChangePDS({ - open, - onOpenChange, -}: { - open: boolean; - onOpenChange: (open: boolean) => void; -}) { - const pds = useAppSelector(selectPDS); - const dispatch = useAppDispatch(); - const [newURL, setNewURL] = useState(""); - return ( - - - - - +
{ - await dispatch(setPDS(newURL)); - onOpenChange(false); + await dispatch(login(handle)); }} > - {/* -
-
-
+
+ ); } diff --git a/js/app/features/bluesky/blueskyProvider.tsx b/js/app/features/bluesky/blueskyProvider.tsx index cfec6606..90454d28 100644 --- a/js/app/features/bluesky/blueskyProvider.tsx +++ b/js/app/features/bluesky/blueskyProvider.tsx @@ -6,6 +6,7 @@ import { getProfile, loadOAuthClient, oauthCallback, + oauthError, selectOAuthSession, selectUserProfile, } from "./blueskySlice"; @@ -31,7 +32,7 @@ export default function BlueskyProvider({ setLastLink(url); if (url.includes("?")) { const params = new URLSearchParams(url.split("?")[1]); - if (params.has("code") && params.has("state") && params.has("iss")) { + if (params.has("error") || params.has("code")) { dispatch(oauthCallback(url)); } } diff --git a/js/app/features/bluesky/blueskySlice.tsx b/js/app/features/bluesky/blueskySlice.tsx index df51bb78..062cff5e 100644 --- a/js/app/features/bluesky/blueskySlice.tsx +++ b/js/app/features/bluesky/blueskySlice.tsx @@ -144,8 +144,24 @@ export const blueskySlice = createAppSlice({ }, ), + oauthError: create.reducer( + ( + state, + { payload }: { payload: { error: string; description: string } }, + ) => { + return { + ...state, + login: { + loading: false, + error: payload.description || payload.error, + }, + status: "loggedOut", + }; + }, + ), + login: create.asyncThunk( - async (pds: string, thunkAPI) => { + async (handle: string, thunkAPI) => { let { bluesky } = thunkAPI.getState() as { bluesky: BlueskyState; }; @@ -156,10 +172,10 @@ export const blueskySlice = createAppSlice({ if (!bluesky.client) { throw new Error("No client"); } - const u = await bluesky.client.authorize(pds); + const u = await bluesky.client.authorize(handle, {}); thunkAPI.dispatch(openLoginLink(u.toString())); // cheeky 500ms delay so you don't see the text flash back - await new Promise((resolve) => setTimeout(resolve, 500)); + await new Promise((resolve) => setTimeout(resolve, 5000)); }, { pending: (state) => { @@ -182,6 +198,7 @@ export const blueskySlice = createAppSlice({ }; }, rejected: (state, action) => { + console.error("login rejected", action.error); return { ...state, login: { @@ -215,6 +232,7 @@ export const blueskySlice = createAppSlice({ ...state, oauthSession: null, pdsAgent: null, + status: "loggedOut", }; }, rejected: (state) => { @@ -253,6 +271,10 @@ export const blueskySlice = createAppSlice({ }, rejected: (state, action) => { clearQueryParams(); + return { + ...state, + status: "loggedOut", + }; // state.status = "failed"; }, }, @@ -266,6 +288,14 @@ export const blueskySlice = createAppSlice({ } const params = new URLSearchParams(url.split("?")[1]); if (!(params.has("code") && params.has("state") && params.has("iss"))) { + if (params.has("error")) { + thunkAPI.dispatch( + oauthError({ + error: params.get("error") ?? "", + description: params.get("error_description") ?? "", + }), + ); + } throw new Error("Missing params, got: " + url); } const { bluesky } = thunkAPI.getState() as { @@ -659,7 +689,7 @@ export const blueskySlice = createAppSlice({ }; }, rejected: (state, action) => { - console.error("getProfile rejected", action.error); + console.error("createStreamKeyRecord rejected", action.error); // state.status = "failed"; }, }, @@ -775,7 +805,7 @@ export const blueskySlice = createAppSlice({ }; }, rejected: (state, action) => { - console.error("getProfile rejected", action.error); + console.error("createLivestreamRecord rejected", action.error); return { ...state, newLivestream: { @@ -912,7 +942,7 @@ export const blueskySlice = createAppSlice({ }; }, rejected: (state, action) => { - console.error("getProfile rejected", action.error); + console.error("createChatProfileRecord rejected", action.error); return { ...state, chatProfile: { @@ -1048,6 +1078,7 @@ export const { golivePost, oauthCallback, setPDS, + oauthError, createStreamKeyRecord, clearStreamKeyRecord, createLivestreamRecord, diff --git a/js/app/features/bluesky/blueskyTypes.tsx b/js/app/features/bluesky/blueskyTypes.tsx index b1ac2d0c..2d98037a 100644 --- a/js/app/features/bluesky/blueskyTypes.tsx +++ b/js/app/features/bluesky/blueskyTypes.tsx @@ -1,4 +1,4 @@ -import { OAuthSession } from "@aquareum/atproto-oauth-client-react-native"; +import { OAuthSession } from "@streamplace/atproto-oauth-client-react-native"; import { Agent } from "@atproto/api"; import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; import { StreamKey } from "features/base/baseSlice"; diff --git a/js/app/features/bluesky/oauthClient.tsx b/js/app/features/bluesky/oauthClient.tsx index 6638fcaf..f7a7984f 100644 --- a/js/app/features/bluesky/oauthClient.tsx +++ b/js/app/features/bluesky/oauthClient.tsx @@ -2,9 +2,10 @@ import { ClientMetadata, clientMetadataSchema, ReactNativeOAuthClient, -} from "@aquareum/atproto-oauth-client-react-native"; +} from "@streamplace/atproto-oauth-client-react-native"; import Constants from "expo-constants"; import { Platform } from "react-native"; +import { isWeb } from "tamagui"; export type StreamplaceOAuthClient = Omit< ReactNativeOAuthClient, @@ -58,14 +59,49 @@ export default async function createOAuthClient( dpop_bound_access_tokens: true, }; } else { + const redirectURI = isWeb + ? `${streamplaceUrl}/login` + : `${streamplaceUrl}/api/app-return`; const res = await fetch( - `${streamplaceUrl}/api/atproto-oauth/${Platform.OS}`, + `${streamplaceUrl}/oauth/downstream/client-metadata.json?redirect_uri=${encodeURIComponent(redirectURI)}`, ); meta = await res.json(); } clientMetadataSchema.parse(meta); return new ReactNativeOAuthClient({ - handleResolver: "https://bsky.social", // backend instances should use a DNS based resolver + fetch: async (input, init) => { + // Normalize input to a Request object + let request: Request; + if (typeof input === "string" || input instanceof URL) { + request = new Request(input, init); + } else { + request = input; + } + + // Lie to the oauth client and use our upstream server instead + if ( + request.url.includes("plc.directory") || + request.url.endsWith("did.json") + ) { + const res = await fetch(request, init); + if (!res.ok) { + return res; + } + const data = await res.json(); + const service = data.service.find((s: any) => s.id === "#atproto_pds"); + if (!service) { + return res; + } + service.serviceEndpoint = streamplaceUrl; + return new Response(JSON.stringify(data), { + status: res.status, + headers: res.headers, + }); + } + + return fetch(request, init); + }, + handleResolver: streamplaceUrl, responseMode: "query", // or "fragment" (frontend only) or "form_post" (backend only) // These must be the same metadata as the one exposed on the diff --git a/js/app/package.json b/js/app/package.json index 500269e8..9cb58f87 100644 --- a/js/app/package.json +++ b/js/app/package.json @@ -25,7 +25,6 @@ "preset": "jest-expo" }, "dependencies": { - "@aquareum/atproto-oauth-client-react-native": "^0.0.1", "@atproto-labs/pipe": "^0.1.0", "@atproto/crypto": "^0.4.2", "@atproto/jwk-jose": "^0.1.2", @@ -40,6 +39,7 @@ "@react-navigation/native": "^6.1.18", "@react-navigation/native-stack": "^6.11.0", "@reduxjs/toolkit": "^2.3.0", + "@streamplace/atproto-oauth-client-react-native": "workspace:*", "@tamagui/config": "^1.123.17", "@tamagui/lucide-icons": "^1.123.17", "@tamagui/toast": "^1.123.17", diff --git a/js/atproto-oauth-client-react-native/.gitignore b/js/atproto-oauth-client-react-native/.gitignore new file mode 100644 index 00000000..76921f5b --- /dev/null +++ b/js/atproto-oauth-client-react-native/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +tsconfig.build.tsbuildinfo diff --git a/js/atproto-oauth-client-react-native/README.md b/js/atproto-oauth-client-react-native/README.md new file mode 100644 index 00000000..fce43837 --- /dev/null +++ b/js/atproto-oauth-client-react-native/README.md @@ -0,0 +1,89 @@ +# atproto OAuth Client for React Native + +This package implements an atproto OAuth client usable on the React Native +platform. It uses [react-native-quick-crypto] for cryptographic operations and +[expo-sqlite] for persistence. Its usage is very similar to the atproto OAuth +client for the browser, so refer to that [README] and [example] for general +usage. Some differences are noted below. + +## expo-sqlite + +This library uses [expo-sqlite] to store the OAuth state and session data in a +SQLite database. The schema is automatically created when the client is +instantiated. + +Because this database is storing sensitive cryptographic keys, it is highly +reccomended to use the optional SQLCipher extension. This can be accomplished in +your app.json file: + +```json +{ + "expo": { + "plugins": [ + [ + "expo-sqlite", + { + "useSQLCipher": true + } + ] + ] + } +} +``` + +## Login and session restore flow + +The basic login flow will involve popping up a web browser and allowing users to +authenticate with their selected PDS. This can be accomplished with the +`expo-web-browser` library: + +```tsx +import { openAuthSessionAsync } from "expo-web-browser"; + +// inside your login onPress, perhaps: +const loginUrl = await oauthClient.authorize(pds); +const res = await openAuthSessionAsync(loginUrl); +if (res.type === "success") { + const params = new URLSearchParams(url.split("?")[1]); + const { session, state } = await oauthClient.callback(params); + console.log(`logged in as ${session.sub}`); +} +``` + +## Development on localhost + +The atproto OAuth specification has a special case for development on localhost, +but it is required to use a redirectUrl that returns to `127.0.0.1` or `[::1]`. +This prevents the localhost OAuth flow from returning you directly to your app. +As a workaround, you can host a static HTML server on 127.0.0.1 that recieves +the incoming OAuth callback and then redirects to your app. (If you have a web +version of your React Native app, you can just use that.) Such a redirect page +might look something like this: + +```tsx +import { useEffect } from "react"; +import { View, Text } from "react-native"; + +export default function AppReturnScreen({ route }) { + useEffect(() => { + document.location.href = `com.example.app:/app-return${document.location.search}`; + }, []); + return ( + + Redirecting you back to the app... + + ); +} +``` + +This flow will work on the iOS simulator and on Android devices provided you've +forwarded the port with `adb reverse`. For testing on iOS hardware, you'll +instead need to set up TLS. + +[react-native-quick-crypto]: + https://github.com/margelo/react-native-quick-crypto +[expo-sqlite]: https://docs.expo.dev/versions/latest/sdk/sqlite/ +[README]: + https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client-browser +[example]: + https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client-browser-example diff --git a/js/atproto-oauth-client-react-native/package.json b/js/atproto-oauth-client-react-native/package.json new file mode 100644 index 00000000..251ae3fc --- /dev/null +++ b/js/atproto-oauth-client-react-native/package.json @@ -0,0 +1,56 @@ +{ + "name": "@streamplace/atproto-oauth-client-react-native", + "version": "0.0.2", + "license": "MIT", + "description": "ATProto OAuth client for React Native", + "keywords": [ + "atproto", + "oauth", + "client", + "node" + ], + "homepage": "https://atproto.com", + "repository": { + "type": "git", + "url": "https://github.com/bluesky-social/atproto", + "directory": "packages/oauth/oauth-client-react-native" + }, + "type": "commonjs", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "dependencies": { + "@atproto-labs/did-resolver": "0.1.5", + "@atproto-labs/handle-resolver-node": "0.1.7", + "@atproto-labs/simple-store": "0.1.1", + "@atproto-labs/simple-store-memory": "0.1.1", + "@atproto/did": "0.1.3", + "@atproto/jwk": "0.1.1", + "@atproto/jwk-jose": "0.1.2", + "@atproto/jwk-webcrypto": "0.1.2", + "@atproto/oauth-client": "0.3.2", + "@atproto/oauth-client-browser": "0.3.2", + "@atproto/oauth-types": "0.2.1", + "abortcontroller-polyfill": "^1.7.6", + "event-target-shim": "^6.0.2", + "expo-sqlite": "^15.0.3", + "jose": "^5.2.0", + "react-native-quick-crypto": "^0.7.7" + }, + "devDependencies": { + "@types/node": "^22.10.1", + "typescript": "^5.6.3" + }, + "scripts": { + "build": "tsc --build tsconfig.build.json", + "postinstall": "yarn run build" + } +} diff --git a/js/atproto-oauth-client-react-native/src/index.ts b/js/atproto-oauth-client-react-native/src/index.ts new file mode 100644 index 00000000..a5ad80d3 --- /dev/null +++ b/js/atproto-oauth-client-react-native/src/index.ts @@ -0,0 +1,4 @@ +import "./polyfills"; + +export * from "@atproto/oauth-client"; +export * from "./oauth-client-react-native"; diff --git a/js/atproto-oauth-client-react-native/src/oauth-client-react-native.native.ts b/js/atproto-oauth-client-react-native/src/oauth-client-react-native.native.ts new file mode 100644 index 00000000..45185b08 --- /dev/null +++ b/js/atproto-oauth-client-react-native/src/oauth-client-react-native.native.ts @@ -0,0 +1,188 @@ +import { SimpleStore } from "@atproto-labs/simple-store"; +import { jwkValidator } from "@atproto/jwk"; +import { JoseKey } from "@atproto/jwk-jose"; +import { + InternalStateData, + OAuthClient, + OAuthClientFetchMetadataOptions, + OAuthClientOptions, + OAuthSession, + Session, + SessionStore, + StateStore, +} from "@atproto/oauth-client"; +import { JWK } from "jose"; +import QuickCrypto from "react-native-quick-crypto"; +import { + CryptoKey, + SubtleAlgorithm, +} from "react-native-quick-crypto/lib/typescript/src/keys"; +import { JoseKeyStore, SQLiteKVStore } from "./sqlite-keystore"; + +export type ReactNativeOAuthClientOptions = Omit< + OAuthClientOptions, + // Provided by this lib + | "runtimeImplementation" + // Provided by this lib but can be overridden + | "sessionStore" + | "stateStore" +> & { + sessionStore?: SessionStore; + stateStore?: StateStore; + didStore?: SimpleStore; +}; + +export type ReactNativeOAuthClientFromMetadataOptions = + OAuthClientFetchMetadataOptions & + Omit; + +export class ReactNativeOAuthClient extends OAuthClient { + didStore: SimpleStore; + + static async fromClientId( + options: ReactNativeOAuthClientFromMetadataOptions, + ) { + const clientMetadata = await OAuthClient.fetchMetadata(options); + return new ReactNativeOAuthClient({ ...options, clientMetadata }); + } + + constructor({ + fetch, + responseMode = "query", + + ...options + }: ReactNativeOAuthClientOptions) { + if (!options.stateStore) { + options.stateStore = new JoseKeyStore( + new SQLiteKVStore("state"), + ); + } + if (!options.sessionStore) { + options.sessionStore = new JoseKeyStore( + new SQLiteKVStore("session"), + ); + } + if (!options.didStore) { + options.didStore = new SQLiteKVStore("did"); + } + super({ + ...options, + + sessionStore: options.sessionStore, + stateStore: options.stateStore, + fetch, + responseMode, + runtimeImplementation: { + createKey: async (algs): Promise => { + console.log("GOT HEREEEE!"); + const errors: unknown[] = []; + for (const alg of algs) { + try { + let subtle = QuickCrypto?.webcrypto?.subtle; + const subalg = toSubtleAlgorithm(alg); + const keyPair = (await subtle.generateKey(subalg, true, [ + "sign", + "verify", + ])) as CryptoKeyPair; + + const ex = (await subtle.exportKey( + "jwk", + keyPair.privateKey as unknown as CryptoKey, + )) as JWK; + ex.alg = alg; + // these have trailing periods sometimes for some reason + for (const k of ["x", "y", "d"]) { + if (ex[k].endsWith(".")) { + ex[k] = ex[k].slice(0, -1); + } + } + + // RNQC doesn't give us a kid, so let's do a quick hash of the key + const kid = QuickCrypto.createHash("sha256") + .update(JSON.stringify(ex)) + .digest("hex"); + const use = "sig"; + + return new JoseKey(jwkValidator.parse({ ...ex, kid, use })); + } catch (err) { + errors.push(err); + } + } + throw new Error("None of the algorithms worked"); + }, + getRandomValues: (length) => + new Uint8Array(QuickCrypto.randomBytes(length)), + digest: (bytes, algorithm) => + QuickCrypto.createHash(algorithm.name) + .update(bytes as unknown as ArrayBuffer) + .digest(), + }, + clientMetadata: options.clientMetadata, + }); + this.didStore = options.didStore; + } + + async init(refresh?: boolean) { + const sub = await this.didStore.get(`(sub)`); + if (sub) { + try { + const session = await this.restore(sub, refresh); + return { session }; + } catch (err) { + this.didStore.del(`(sub)`); + throw err; + } + } + } + + async callback(params: URLSearchParams): Promise<{ + session: OAuthSession; + state: string | null; + }> { + const { session, state } = await super.callback(params); + await this.didStore.set(`(sub)`, session.sub); + return { session, state }; + } +} + +export function toSubtleAlgorithm( + alg: string, + crv?: string, + options?: { modulusLength?: number }, +): SubtleAlgorithm { + switch (alg) { + case "PS256": + case "PS384": + case "PS512": + return { + name: "RSA-PSS", + hash: `SHA-${alg.slice(-3) as "256" | "384" | "512"}`, + modulusLength: options?.modulusLength ?? 2048, + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), + }; + case "RS256": + case "RS384": + case "RS512": + return { + name: "RSASSA-PKCS1-v1_5", + hash: `SHA-${alg.slice(-3) as "256" | "384" | "512"}`, + modulusLength: options?.modulusLength ?? 2048, + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), + }; + case "ES256": + case "ES384": + return { + name: "ECDSA", + namedCurve: `P-${alg.slice(-3) as "256" | "384"}`, + }; + case "ES512": + return { + name: "ECDSA", + namedCurve: "P-521", + }; + default: + // https://github.com/w3c/webcrypto/issues/82#issuecomment-849856773 + + throw new TypeError(`Unsupported alg "${alg}"`); + } +} diff --git a/js/atproto-oauth-client-react-native/src/oauth-client-react-native.ts b/js/atproto-oauth-client-react-native/src/oauth-client-react-native.ts new file mode 100644 index 00000000..2b3d2d65 --- /dev/null +++ b/js/atproto-oauth-client-react-native/src/oauth-client-react-native.ts @@ -0,0 +1,4 @@ +// browser fallback +// export * from "@atproto/oauth-client-browser"; +import { BrowserOAuthClient } from "@atproto/oauth-client-browser"; +export { BrowserOAuthClient as ReactNativeOAuthClient }; diff --git a/js/atproto-oauth-client-react-native/src/polyfills.native.ts b/js/atproto-oauth-client-react-native/src/polyfills.native.ts new file mode 100644 index 00000000..433b4882 --- /dev/null +++ b/js/atproto-oauth-client-react-native/src/polyfills.native.ts @@ -0,0 +1,36 @@ +import { Event, EventTarget } from "event-target-shim"; +import { install as installRNQC } from "react-native-quick-crypto"; + +// Polyfill for the `throwIfAborted` method of the AbortController +// used in @atproto/oauth-client +import "abortcontroller-polyfill/dist/polyfill-patch-fetch"; + +// Polyfill for jose. It tries to detect whether it's been passed a CryptoKey +// instance, and isn't willing to accept RNQC's equivalent. So, this ensures that +// `key instanceof CryptoKey` will always be true. +// @ts-ignore +global.CryptoKey = Object; + +// This is needed to populate the `crypto` global for jose's export here +// https://github.com/panva/jose/blob/1e8b430b08a18a18883a69e7991832c9c602ca1a/src/runtime/browser/webcrypto.ts#L1 +installRNQC(); + +// These two are needed for @atproto/oauth-client's `CustomEventTarget` to work. +// @ts-ignore +global.EventTarget = EventTarget; +// @ts-ignore +global.Event = Event; + +// And finally, this happens on React Native with every possible input: +// URL.canParse("http://example.com") => false +// I do not know why. Used in @atproto/oauth and @atproto/common-web +if (!URL.canParse("http://example.com")) { + URL.canParse = (url: string | URL, base?: string) => { + try { + new URL(url, base); + return true; + } catch (e) { + return false; + } + }; +} diff --git a/js/atproto-oauth-client-react-native/src/polyfills.ts b/js/atproto-oauth-client-react-native/src/polyfills.ts new file mode 100644 index 00000000..e69de29b diff --git a/js/atproto-oauth-client-react-native/src/sqlite-keystore.ts b/js/atproto-oauth-client-react-native/src/sqlite-keystore.ts new file mode 100644 index 00000000..65db7d12 --- /dev/null +++ b/js/atproto-oauth-client-react-native/src/sqlite-keystore.ts @@ -0,0 +1,69 @@ +import { SimpleStore } from "@atproto-labs/simple-store"; +import { jwkValidator, Key } from "@atproto/jwk"; +import { JoseKey } from "@atproto/jwk-jose"; +import Storage from "expo-sqlite/kv-store"; + +interface HasDPoPKey { + dpopKey: Key | undefined; +} + +const NAMESPACE = `@@atproto/oauth-client-react-native`; + +/** + * An expo-sqlite store that handles serializing and deserializing + * our Jose DPoP keys. Wraps SQLiteKVStore or whatever other SimpleStore + * that a user might provide. + */ +export class JoseKeyStore { + private store: SimpleStore; + constructor(store: SimpleStore) { + this.store = store; + } + + async get(key: string): Promise { + const itemStr = await this.store.get(key); + if (!itemStr) return undefined; + const item = JSON.parse(itemStr) as T; + if (item.dpopKey) { + item.dpopKey = new JoseKey(jwkValidator.parse(item.dpopKey)); + } + return item; + } + + async set(key: string, value: T): Promise { + if (value.dpopKey) { + value = { + ...value, + dpopKey: (value.dpopKey as JoseKey).privateJwk, + }; + } + return await this.store.set(key, JSON.stringify(value)); + } + + async del(key: string): Promise { + return await this.store.del(key); + } +} + +/** + * Simple wrapper around expo-sqlite's KVStore. Default implementation + * unless a user brings their own KV store. + */ +export class SQLiteKVStore implements SimpleStore { + private namespace: string; + constructor(namespace: string) { + this.namespace = `${NAMESPACE}:${namespace}`; + } + + async get(key: string): Promise { + return (await Storage.getItem(`${this.namespace}:${key}`)) ?? undefined; + } + + async set(key: string, value: string): Promise { + return await Storage.setItem(`${this.namespace}:${key}`, value); + } + + async del(key: string): Promise { + return await Storage.removeItem(`${this.namespace}:${key}`); + } +} diff --git a/js/atproto-oauth-client-react-native/tsconfig.build.json b/js/atproto-oauth-client-react-native/tsconfig.build.json new file mode 100644 index 00000000..35fdfad7 --- /dev/null +++ b/js/atproto-oauth-client-react-native/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "../app/tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["./src"] +} diff --git a/js/atproto-oauth-client-react-native/tsconfig.build.tsbuildinfo b/js/atproto-oauth-client-react-native/tsconfig.build.tsbuildinfo deleted file mode 100644 index 35e6285d..00000000 --- a/js/atproto-oauth-client-react-native/tsconfig.build.tsbuildinfo +++ /dev/null @@ -1 +0,0 @@ -{"fileNames":["./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/typescript/lib/lib.es2023.d.ts","./node_modules/typescript/lib/lib.es2024.d.ts","./node_modules/typescript/lib/lib.esnext.d.ts","./node_modules/typescript/lib/lib.dom.d.ts","./node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/typescript/lib/lib.es2022.object.d.ts","./node_modules/typescript/lib/lib.es2022.string.d.ts","./node_modules/typescript/lib/lib.es2022.regexp.d.ts","./node_modules/typescript/lib/lib.es2023.array.d.ts","./node_modules/typescript/lib/lib.es2023.collection.d.ts","./node_modules/typescript/lib/lib.es2023.intl.d.ts","./node_modules/typescript/lib/lib.es2024.arraybuffer.d.ts","./node_modules/typescript/lib/lib.es2024.collection.d.ts","./node_modules/typescript/lib/lib.es2024.object.d.ts","./node_modules/typescript/lib/lib.es2024.promise.d.ts","./node_modules/typescript/lib/lib.es2024.regexp.d.ts","./node_modules/typescript/lib/lib.es2024.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2024.string.d.ts","./node_modules/typescript/lib/lib.esnext.array.d.ts","./node_modules/typescript/lib/lib.esnext.collection.d.ts","./node_modules/typescript/lib/lib.esnext.intl.d.ts","./node_modules/typescript/lib/lib.esnext.disposable.d.ts","./node_modules/typescript/lib/lib.esnext.promise.d.ts","./node_modules/typescript/lib/lib.esnext.decorators.d.ts","./node_modules/typescript/lib/lib.esnext.iterator.d.ts","./node_modules/typescript/lib/lib.esnext.float16.d.ts","./node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.legacy.d.ts","../../node_modules/tslib/tslib.d.ts","../../node_modules/@types/react/global.d.ts","../../node_modules/csstype/index.d.ts","../../node_modules/@types/prop-types/index.d.ts","../../node_modules/@types/react/index.d.ts","../../node_modules/@types/react/jsx-runtime.d.ts","./src/polyfills.ts","../../node_modules/@atproto/did/node_modules/zod/lib/helpers/typealiases.d.ts","../../node_modules/@atproto/did/node_modules/zod/lib/helpers/util.d.ts","../../node_modules/@atproto/did/node_modules/zod/lib/zoderror.d.ts","../../node_modules/@atproto/did/node_modules/zod/lib/locales/en.d.ts","../../node_modules/@atproto/did/node_modules/zod/lib/errors.d.ts","../../node_modules/@atproto/did/node_modules/zod/lib/helpers/parseutil.d.ts","../../node_modules/@atproto/did/node_modules/zod/lib/helpers/enumutil.d.ts","../../node_modules/@atproto/did/node_modules/zod/lib/helpers/errorutil.d.ts","../../node_modules/@atproto/did/node_modules/zod/lib/helpers/partialutil.d.ts","../../node_modules/@atproto/did/node_modules/zod/lib/types.d.ts","../../node_modules/@atproto/did/node_modules/zod/lib/external.d.ts","../../node_modules/@atproto/did/node_modules/zod/lib/index.d.ts","../../node_modules/@atproto/did/node_modules/zod/index.d.ts","../../node_modules/@atproto/did/dist/did.d.ts","../../node_modules/@atproto/did/dist/atproto.d.ts","../../node_modules/@atproto/did/dist/did-document.d.ts","../../node_modules/@atproto/did/dist/did-error.d.ts","../../node_modules/@atproto/did/dist/methods/plc.d.ts","../../node_modules/@atproto/did/dist/methods/web.d.ts","../../node_modules/@atproto/did/dist/methods.d.ts","../../node_modules/@atproto/did/dist/index.d.ts","../../node_modules/@atproto-labs/simple-store/dist/simple-store.d.ts","../../node_modules/@atproto-labs/simple-store/dist/cached-getter.d.ts","../../node_modules/@atproto-labs/simple-store/dist/index.d.ts","../../node_modules/@atproto-labs/simple-store-memory/dist/index.d.ts","../../node_modules/@atproto-labs/did-resolver/dist/did-method.d.ts","../../node_modules/@atproto-labs/did-resolver/dist/did-resolver.d.ts","../../node_modules/@atproto-labs/did-resolver/dist/did-cache.d.ts","../../node_modules/@atproto-labs/did-resolver/dist/did-cache-memory.d.ts","../../node_modules/@atproto-labs/did-resolver/dist/did-resolver-base.d.ts","../../node_modules/@atproto-labs/fetch/dist/fetch-error.d.ts","../../node_modules/@atproto-labs/fetch/dist/util.d.ts","../../node_modules/@atproto-labs/fetch/dist/fetch.d.ts","../../node_modules/@atproto-labs/fetch/dist/fetch-request.d.ts","../../node_modules/@atproto-labs/pipe/dist/transformer.d.ts","../../node_modules/@atproto-labs/pipe/dist/pipe.d.ts","../../node_modules/@atproto-labs/pipe/dist/index.d.ts","../../node_modules/@atproto-labs/fetch/node_modules/zod/index.d.ts","../../node_modules/@atproto-labs/fetch/dist/fetch-response.d.ts","../../node_modules/@atproto-labs/fetch/dist/fetch-wrap.d.ts","../../node_modules/@atproto-labs/fetch/dist/index.d.ts","../../node_modules/@atproto-labs/did-resolver/dist/methods/plc.d.ts","../../node_modules/@atproto-labs/did-resolver/dist/methods/web.d.ts","../../node_modules/@atproto-labs/did-resolver/dist/util.d.ts","../../node_modules/@atproto-labs/did-resolver/dist/did-resolver-common.d.ts","../../node_modules/@atproto-labs/did-resolver/dist/methods.d.ts","../../node_modules/@atproto-labs/did-resolver/dist/index.d.ts","../../node_modules/@atproto-labs/handle-resolver/dist/types.d.ts","../../node_modules/@atproto-labs/handle-resolver/node_modules/zod/index.d.ts","../../node_modules/@atproto-labs/handle-resolver/dist/app-view-handle-resolver.d.ts","../../node_modules/@atproto-labs/handle-resolver/dist/internal-resolvers/dns-handle-resolver.d.ts","../../node_modules/@atproto-labs/handle-resolver/dist/internal-resolvers/well-known-handler-resolver.d.ts","../../node_modules/@atproto-labs/handle-resolver/dist/atproto-handle-resolver.d.ts","../../node_modules/@atproto-labs/handle-resolver/dist/atproto-doh-handle-resolver.d.ts","../../node_modules/@atproto-labs/handle-resolver/dist/cached-handle-resolver.d.ts","../../node_modules/@atproto-labs/handle-resolver/dist/index.d.ts","../../node_modules/@atproto/oauth-types/dist/constants.d.ts","../../node_modules/@atproto/oauth-types/node_modules/zod/index.d.ts","../../node_modules/@atproto/oauth-types/dist/uri.d.ts","../../node_modules/@atproto/oauth-types/dist/util.d.ts","../../node_modules/@atproto/oauth-types/dist/oauth-redirect-uri.d.ts","../../node_modules/@atproto/oauth-types/dist/oauth-scope.d.ts","../../node_modules/@atproto/oauth-types/dist/oauth-client-id-loopback.d.ts","../../node_modules/@atproto/oauth-types/dist/oauth-client-metadata.d.ts","../../node_modules/@atproto/oauth-types/dist/atproto-loopback-client-metadata.d.ts","../../node_modules/@atproto/oauth-types/dist/oauth-access-token.d.ts","../../node_modules/@atproto/oauth-types/dist/oauth-authorization-code-grant-token-request.d.ts","../../node_modules/@atproto/oauth-types/dist/oauth-authorization-details.d.ts","../../node_modules/@atproto/oauth-types/dist/oauth-authorization-request-jar.d.ts","../../node_modules/@atproto/oauth-types/dist/oauth-authorization-request-par.d.ts","../../node_modules/@atproto/oauth-types/dist/oauth-authorization-request-parameters.d.ts","../../node_modules/@atproto/oauth-types/dist/oauth-authorization-request-query.d.ts","../../node_modules/@atproto/oauth-types/dist/oauth-authorization-request-uri.d.ts","../../node_modules/@atproto/oauth-types/dist/oauth-authorization-server-metadata.d.ts","../../node_modules/@atproto/oauth-types/dist/oauth-client-credentials-grant-token-request.d.ts","../../node_modules/@atproto/oauth-types/dist/oauth-client-credentials.d.ts","../../node_modules/@atproto/oauth-types/dist/oauth-client-id-discoverable.d.ts","../../node_modules/@atproto/oauth-types/dist/oauth-client-id.d.ts","../../node_modules/@atproto/oauth-types/dist/oauth-endpoint-auth-method.d.ts","../../node_modules/@atproto/oauth-types/dist/oauth-endpoint-name.d.ts","../../node_modules/@atproto/oauth-types/dist/oauth-grant-type.d.ts","../../node_modules/@atproto/oauth-types/dist/oauth-token-type.d.ts","../../node_modules/@atproto/oauth-types/dist/oauth-introspection-response.d.ts","../../node_modules/@atproto/oauth-types/dist/oauth-issuer-identifier.d.ts","../../node_modules/@atproto/oauth-types/dist/oauth-par-response.d.ts","../../node_modules/@atproto/oauth-types/dist/oauth-password-grant-token-request.d.ts","../../node_modules/@atproto/oauth-types/dist/oauth-protected-resource-metadata.d.ts","../../node_modules/@atproto/oauth-types/dist/oauth-refresh-token-grant-token-request.d.ts","../../node_modules/@atproto/oauth-types/dist/oauth-refresh-token.d.ts","../../node_modules/@atproto/oauth-types/dist/oauth-request-uri.d.ts","../../node_modules/@atproto/oauth-types/dist/oauth-response-mode.d.ts","../../node_modules/@atproto/oauth-types/dist/oauth-response-type.d.ts","../../node_modules/@atproto/oauth-types/dist/oauth-token-identification.d.ts","../../node_modules/@atproto/oauth-types/dist/oauth-token-request.d.ts","../../node_modules/@atproto/oauth-types/dist/oauth-token-response.d.ts","../../node_modules/@atproto/oauth-types/dist/oidc-claims-parameter.d.ts","../../node_modules/@atproto/oauth-types/dist/oidc-claims-properties.d.ts","../../node_modules/@atproto/oauth-types/dist/oidc-entity-type.d.ts","../../node_modules/@atproto/oauth-types/dist/index.d.ts","../../node_modules/@atproto/oauth-client/dist/oauth-authorization-server-metadata-resolver.d.ts","../../node_modules/@atproto/oauth-client/dist/oauth-callback-error.d.ts","../../node_modules/@atproto-labs/identity-resolver/dist/identity-resolver.d.ts","../../node_modules/@atproto-labs/identity-resolver/dist/index.d.ts","../../node_modules/@atproto/jwk/node_modules/zod/index.d.ts","../../node_modules/@atproto/jwk/dist/jwk.d.ts","../../node_modules/@atproto/jwk/dist/alg.d.ts","../../node_modules/@atproto/jwk/dist/errors.d.ts","../../node_modules/@atproto/jwk/dist/jwks.d.ts","../../node_modules/@atproto/jwk/dist/jwt.d.ts","../../node_modules/@atproto/jwk/dist/jwt-decode.d.ts","../../node_modules/@atproto/jwk/dist/util.d.ts","../../node_modules/@atproto/jwk/dist/jwt-verify.d.ts","../../node_modules/@atproto/jwk/dist/key.d.ts","../../node_modules/@atproto/jwk/dist/keyset.d.ts","../../node_modules/@atproto/jwk/dist/index.d.ts","../../node_modules/@atproto/oauth-client/dist/oauth-protected-resource-metadata-resolver.d.ts","../../node_modules/@atproto/oauth-client/dist/oauth-resolver.d.ts","../../node_modules/@atproto/oauth-client/node_modules/zod/index.d.ts","../../node_modules/@atproto/oauth-client/dist/util.d.ts","../../node_modules/@atproto/oauth-client/dist/atproto-token-response.d.ts","../../node_modules/@atproto/oauth-client/dist/runtime-implementation.d.ts","../../node_modules/@atproto/oauth-client/dist/runtime.d.ts","../../node_modules/@atproto/oauth-client/dist/types.d.ts","../../node_modules/@atproto/oauth-client/dist/oauth-server-agent.d.ts","../../node_modules/@atproto/oauth-client/dist/oauth-server-factory.d.ts","../../node_modules/@atproto/oauth-client/dist/errors/token-invalid-error.d.ts","../../node_modules/@atproto/oauth-client/dist/errors/token-refresh-error.d.ts","../../node_modules/@atproto/oauth-client/dist/errors/token-revoked-error.d.ts","../../node_modules/@atproto/oauth-client/dist/session-getter.d.ts","../../node_modules/@atproto/oauth-client/dist/oauth-session.d.ts","../../node_modules/@atproto/oauth-client/dist/state-store.d.ts","../../node_modules/@atproto/oauth-client/dist/oauth-client.d.ts","../../node_modules/@atproto/oauth-client/dist/oauth-resolver-error.d.ts","../../node_modules/@atproto/oauth-client/dist/oauth-response-error.d.ts","../../node_modules/@atproto/oauth-client/dist/index.d.ts","./node_modules/@atproto/oauth-client-browser/dist/disposable-polyfill/index.d.ts","../../node_modules/jose/dist/types/types.d.ts","../../node_modules/jose/dist/types/jwe/compact/decrypt.d.ts","../../node_modules/jose/dist/types/jwe/flattened/decrypt.d.ts","../../node_modules/jose/dist/types/jwe/general/decrypt.d.ts","../../node_modules/jose/dist/types/jwe/general/encrypt.d.ts","../../node_modules/jose/dist/types/jws/compact/verify.d.ts","../../node_modules/jose/dist/types/jws/flattened/verify.d.ts","../../node_modules/jose/dist/types/jws/general/verify.d.ts","../../node_modules/jose/dist/types/jwt/verify.d.ts","../../node_modules/jose/dist/types/jwt/decrypt.d.ts","../../node_modules/jose/dist/types/jwt/produce.d.ts","../../node_modules/jose/dist/types/jwe/compact/encrypt.d.ts","../../node_modules/jose/dist/types/jwe/flattened/encrypt.d.ts","../../node_modules/jose/dist/types/jws/compact/sign.d.ts","../../node_modules/jose/dist/types/jws/flattened/sign.d.ts","../../node_modules/jose/dist/types/jws/general/sign.d.ts","../../node_modules/jose/dist/types/jwt/sign.d.ts","../../node_modules/jose/dist/types/jwt/encrypt.d.ts","../../node_modules/jose/dist/types/jwk/thumbprint.d.ts","../../node_modules/jose/dist/types/jwk/embedded.d.ts","../../node_modules/jose/dist/types/jwks/local.d.ts","../../node_modules/jose/dist/types/jwks/remote.d.ts","../../node_modules/jose/dist/types/jwt/unsecured.d.ts","../../node_modules/jose/dist/types/key/export.d.ts","../../node_modules/jose/dist/types/key/import.d.ts","../../node_modules/jose/dist/types/util/decode_protected_header.d.ts","../../node_modules/jose/dist/types/util/decode_jwt.d.ts","../../node_modules/jose/dist/types/util/errors.d.ts","../../node_modules/jose/dist/types/key/generate_key_pair.d.ts","../../node_modules/jose/dist/types/key/generate_secret.d.ts","../../node_modules/jose/dist/types/util/base64url.d.ts","../../node_modules/jose/dist/types/util/runtime.d.ts","../../node_modules/jose/dist/types/index.d.ts","../../node_modules/@atproto/jwk-jose/dist/jose-key.d.ts","../../node_modules/@atproto/jwk-jose/dist/index.d.ts","../../node_modules/@atproto/jwk-webcrypto/dist/webcrypto-key.d.ts","../../node_modules/@atproto/jwk-webcrypto/dist/index.d.ts","./node_modules/@atproto/oauth-client-browser/dist/util.d.ts","./node_modules/@atproto/oauth-client-browser/dist/browser-oauth-client.d.ts","./node_modules/@atproto/oauth-client-browser/dist/errors.d.ts","./node_modules/@atproto/oauth-client-browser/dist/index.d.ts","./src/oauth-client-react-native.ts","./src/index.ts","../../node_modules/@craftzdog/react-native-buffer/index.d.ts","../../node_modules/safe-buffer/index.d.ts","../../node_modules/react-native-quick-crypto/node_modules/buffer/index.d.ts","../../node_modules/react-native-quick-crypto/lib/typescript/src/aes.d.ts","../../node_modules/react-native-quick-crypto/lib/typescript/src/nativequickcrypto/aes.d.ts","../../node_modules/react-native-quick-crypto/lib/typescript/src/nativequickcrypto/sig.d.ts","../../node_modules/react-native-quick-crypto/lib/typescript/src/nativequickcrypto/keygen.d.ts","../../node_modules/react-native-quick-crypto/lib/typescript/src/cipher.d.ts","../../node_modules/react-native-quick-crypto/lib/typescript/src/nativequickcrypto/cipher.d.ts","../../node_modules/react-native-quick-crypto/lib/typescript/src/rsa.d.ts","../../node_modules/react-native-quick-crypto/lib/typescript/src/nativequickcrypto/rsa.d.ts","../../node_modules/react-native-quick-crypto/lib/typescript/src/nativequickcrypto/webcrypto.d.ts","../../node_modules/react-native-quick-crypto/lib/typescript/src/keys.d.ts","../../node_modules/react-native-quick-crypto/lib/typescript/src/hashnames.d.ts","../../node_modules/react-native-quick-crypto/lib/typescript/src/utils.d.ts","../../node_modules/react-native-quick-crypto/lib/typescript/src/random.d.ts","../../node_modules/react-native-quick-crypto/lib/typescript/src/sig.d.ts","../../node_modules/react-native-quick-crypto/lib/typescript/src/hmac.d.ts","../../node_modules/react-native-quick-crypto/lib/typescript/src/hash.d.ts","../../node_modules/react-native-quick-crypto/lib/typescript/src/subtle.d.ts","../../node_modules/react-native-quick-crypto/lib/typescript/src/keygen.d.ts","../../node_modules/react-native-quick-crypto/lib/typescript/src/index.d.ts","../../node_modules/expo-sqlite/build/storage.d.ts","../../node_modules/expo-sqlite/kv-store.d.ts","./src/sqlite-keystore.ts","./src/oauth-client-react-native.native.ts","../../node_modules/event-target-shim/index.d.ts","./src/polyfills.native.ts","../../node_modules/@types/node/compatibility/disposable.d.ts","../../node_modules/@types/node/compatibility/indexable.d.ts","../../node_modules/@types/node/compatibility/iterators.d.ts","../../node_modules/@types/node/compatibility/index.d.ts","../../node_modules/@types/node/globals.typedarray.d.ts","../../node_modules/@types/node/buffer.buffer.d.ts","../../node_modules/buffer/index.d.ts","../../node_modules/@types/node/node_modules/undici-types/header.d.ts","../../node_modules/@types/node/node_modules/undici-types/readable.d.ts","../../node_modules/@types/node/node_modules/undici-types/file.d.ts","../../node_modules/@types/node/node_modules/undici-types/fetch.d.ts","../../node_modules/@types/node/node_modules/undici-types/formdata.d.ts","../../node_modules/@types/node/node_modules/undici-types/connector.d.ts","../../node_modules/@types/node/node_modules/undici-types/client.d.ts","../../node_modules/@types/node/node_modules/undici-types/errors.d.ts","../../node_modules/@types/node/node_modules/undici-types/dispatcher.d.ts","../../node_modules/@types/node/node_modules/undici-types/global-dispatcher.d.ts","../../node_modules/@types/node/node_modules/undici-types/global-origin.d.ts","../../node_modules/@types/node/node_modules/undici-types/pool-stats.d.ts","../../node_modules/@types/node/node_modules/undici-types/pool.d.ts","../../node_modules/@types/node/node_modules/undici-types/handlers.d.ts","../../node_modules/@types/node/node_modules/undici-types/balanced-pool.d.ts","../../node_modules/@types/node/node_modules/undici-types/agent.d.ts","../../node_modules/@types/node/node_modules/undici-types/mock-interceptor.d.ts","../../node_modules/@types/node/node_modules/undici-types/mock-agent.d.ts","../../node_modules/@types/node/node_modules/undici-types/mock-client.d.ts","../../node_modules/@types/node/node_modules/undici-types/mock-pool.d.ts","../../node_modules/@types/node/node_modules/undici-types/mock-errors.d.ts","../../node_modules/@types/node/node_modules/undici-types/proxy-agent.d.ts","../../node_modules/@types/node/node_modules/undici-types/env-http-proxy-agent.d.ts","../../node_modules/@types/node/node_modules/undici-types/retry-handler.d.ts","../../node_modules/@types/node/node_modules/undici-types/retry-agent.d.ts","../../node_modules/@types/node/node_modules/undici-types/api.d.ts","../../node_modules/@types/node/node_modules/undici-types/interceptors.d.ts","../../node_modules/@types/node/node_modules/undici-types/util.d.ts","../../node_modules/@types/node/node_modules/undici-types/cookies.d.ts","../../node_modules/@types/node/node_modules/undici-types/patch.d.ts","../../node_modules/@types/node/node_modules/undici-types/websocket.d.ts","../../node_modules/@types/node/node_modules/undici-types/eventsource.d.ts","../../node_modules/@types/node/node_modules/undici-types/filereader.d.ts","../../node_modules/@types/node/node_modules/undici-types/diagnostics-channel.d.ts","../../node_modules/@types/node/node_modules/undici-types/content-type.d.ts","../../node_modules/@types/node/node_modules/undici-types/cache.d.ts","../../node_modules/@types/node/node_modules/undici-types/index.d.ts","../../node_modules/@types/node/globals.d.ts","../../node_modules/@types/node/assert.d.ts","../../node_modules/@types/node/assert/strict.d.ts","../../node_modules/@types/node/async_hooks.d.ts","../../node_modules/@types/node/buffer.d.ts","../../node_modules/@types/node/child_process.d.ts","../../node_modules/@types/node/cluster.d.ts","../../node_modules/@types/node/console.d.ts","../../node_modules/@types/node/constants.d.ts","../../node_modules/@types/node/crypto.d.ts","../../node_modules/@types/node/dgram.d.ts","../../node_modules/@types/node/diagnostics_channel.d.ts","../../node_modules/@types/node/dns.d.ts","../../node_modules/@types/node/dns/promises.d.ts","../../node_modules/@types/node/domain.d.ts","../../node_modules/@types/node/dom-events.d.ts","../../node_modules/@types/node/events.d.ts","../../node_modules/@types/node/fs.d.ts","../../node_modules/@types/node/fs/promises.d.ts","../../node_modules/@types/node/http.d.ts","../../node_modules/@types/node/http2.d.ts","../../node_modules/@types/node/https.d.ts","../../node_modules/@types/node/inspector.d.ts","../../node_modules/@types/node/module.d.ts","../../node_modules/@types/node/net.d.ts","../../node_modules/@types/node/os.d.ts","../../node_modules/@types/node/path.d.ts","../../node_modules/@types/node/perf_hooks.d.ts","../../node_modules/@types/node/process.d.ts","../../node_modules/@types/node/punycode.d.ts","../../node_modules/@types/node/querystring.d.ts","../../node_modules/@types/node/readline.d.ts","../../node_modules/@types/node/readline/promises.d.ts","../../node_modules/@types/node/repl.d.ts","../../node_modules/@types/node/sea.d.ts","../../node_modules/@types/node/sqlite.d.ts","../../node_modules/@types/node/stream.d.ts","../../node_modules/@types/node/stream/promises.d.ts","../../node_modules/@types/node/stream/consumers.d.ts","../../node_modules/@types/node/stream/web.d.ts","../../node_modules/@types/node/string_decoder.d.ts","../../node_modules/@types/node/test.d.ts","../../node_modules/@types/node/timers.d.ts","../../node_modules/@types/node/timers/promises.d.ts","../../node_modules/@types/node/tls.d.ts","../../node_modules/@types/node/trace_events.d.ts","../../node_modules/@types/node/tty.d.ts","../../node_modules/@types/node/url.d.ts","../../node_modules/@types/node/util.d.ts","../../node_modules/@types/node/v8.d.ts","../../node_modules/@types/node/vm.d.ts","../../node_modules/@types/node/wasi.d.ts","../../node_modules/@types/node/worker_threads.d.ts","../../node_modules/@types/node/zlib.d.ts","../../node_modules/@types/node/index.d.ts"],"fileIdsList":[[185,221,260,299,342],[299,342],[221,259,260,261,262,299,342],[80,85,221,264,299,342],[80,85,110,201,221,255,257,278,287,290,299,342],[80,85,263,299,342],[80,85,287,292,299,342],[80,85,110,201,257,289,299,342],[107,111,114,299,342],[107,110,112,113,299,342],[107,299,342],[107,112,113,299,342],[116,128,129,130,299,342],[107,112,299,342],[107,112,113,114,115,130,131,132,299,342],[128,129,299,342],[107,112,127,299,342],[117,119,299,342],[99,117,118,123,299,342],[119,299,342],[118,299,342],[117,118,119,120,125,126,299,342],[98,299,342],[99,134,299,342],[134,139,299,342],[134,137,138,299,342],[110,134,299,342],[134,136,139,140,141,299,342],[134,299,342],[133,142,299,342],[188,299,342],[121,122,299,342],[121,299,342],[110,299,342],[108,299,342],[108,109,299,342],[99,100,299,342],[99,299,342],[100,101,102,103,106,299,342],[104,105,299,342],[100,299,342],[89,90,299,342],[87,88,89,91,92,96,299,342],[88,89,299,342],[97,299,342],[89,299,342],[87,88,89,92,93,94,95,299,342],[87,88,98,299,342],[256,299,342],[201,255,299,342],[258,299,342],[201,257,299,342],[191,299,342],[191,192,193,194,195,196,197,198,199,200,299,342],[195,299,342],[195,197,299,342],[191,195,198,299,342],[194,195,197,198,199,299,342],[99,205,299,342],[107,127,133,142,185,186,187,202,207,209,210,211,212,213,214,215,216,217,218,219,220,299,342],[110,127,185,299,342],[127,133,142,185,186,189,201,202,203,205,207,208,209,210,211,215,216,217,299,342],[185,186,189,202,299,342],[127,299,342],[107,110,127,185,201,203,206,208,209,299,342],[127,185,186,201,203,208,209,210,299,342],[107,127,185,206,210,215,299,342],[201,205,299,342],[201,207,299,342],[107,110,201,208,210,211,212,213,214,299,342],[110,201,299,342],[99,185,205,299,342],[149,150,299,342],[143,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,299,342],[99,147,148,299,342],[154,168,299,342],[299,339,342],[299,341,342],[342],[299,342,347,377],[299,342,343,348,354,355,362,374,385],[299,342,343,344,354,362],[294,295,296,299,342],[299,342,345,386],[299,342,346,347,355,363],[299,342,347,374,382],[299,342,348,350,354,362],[299,341,342,349],[299,342,350,351],[299,342,354],[299,342,352,354],[299,341,342,354],[299,342,354,355,356,374,385],[299,342,354,355,356,369,374,377],[299,337,342,390],[299,337,342,350,354,357,362,374,385],[299,342,354,355,357,358,362,374,382,385],[299,342,357,359,374,382,385],[297,298,299,338,339,340,341,342,343,344,345,346,347,348,349,350,351,352,353,354,355,356,357,358,359,360,361,362,363,364,365,366,367,368,369,370,371,372,373,374,375,376,377,378,379,380,381,382,383,384,385,386,387,388,389,390,391],[299,342,354,360],[299,342,361,385],[299,342,350,354,362,374],[299,309,313,342,385],[299,309,342,374,385],[299,304,342],[299,306,309,342,382,385],[299,342,362,382],[299,342,392],[299,304,342,392],[299,306,309,342,362,385],[299,301,302,305,308,342,354,374,385],[299,309,316,342],[299,301,307,342],[299,309,330,331,342],[299,305,309,342,377,385,392],[299,330,342,392],[299,303,304,342,392],[299,309,342],[299,303,304,305,306,307,308,309,310,311,313,314,315,316,317,318,319,320,321,322,323,324,325,326,327,328,329,331,332,333,334,335,336,342],[299,309,324,342],[299,309,316,317,342],[299,307,309,317,318,342],[299,308,342],[299,301,304,309,342],[299,309,313,317,318,342],[299,313,342],[299,307,309,312,342,385],[299,301,306,309,316,342],[299,342,374],[299,304,309,330,342,390,392],[299,342,363],[299,342,364],[299,341,342,365],[299,339,340,341,342,343,344,345,346,347,348,349,350,351,352,354,355,356,357,358,359,360,361,362,363,364,365,366,367,368,369,370,371,372,373,374,375,376,377,378,379,380,381,382,383,384,385,386,387,388,389,390,391],[299,342,367],[299,342,368],[299,342,354,369,370],[299,342,369,371,386,388],[299,342,354,374,375,377],[299,342,376,377],[299,342,374,375],[299,342,377],[299,342,378],[299,339,342,374],[299,342,354,380,381],[299,342,380,381],[299,342,347,362,374,382],[299,342,383],[299,342,362,384],[299,342,357,368,385],[299,342,347,386],[299,342,374,387],[299,342,361,388],[299,342,389],[299,342,347,354,356,365,374,385,388,390],[299,342,374,391],[81,82,83,299,342],[84,299,342],[288,299,342],[223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,299,342],[223,299,342],[223,233,299,342],[278,280,299,342],[266,277,278,280,299,342,347],[266,278,280,299,342],[278,299,342],[266,280,299,342],[266,273,278,280,281,282,283,284,285,286,299,342],[273,277,280,299,342],[269,277,278,299,342],[266,273,278,280,299,342],[277,299,342],[275,277,278,299,342],[270,271,272,274,276,278,280,299,342],[266,267,278,279,299,342,347]],"fileInfos":[{"version":"69684132aeb9b5642cbcd9e22dff7818ff0ee1aa831728af0ecf97d3364d5546","affectsGlobalScope":true,"impliedFormat":1},{"version":"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","impliedFormat":1},{"version":"3facaf05f0c5fc569c5649dd359892c98a85557e3e0c847964caeb67076f4d75","impliedFormat":1},{"version":"e44bb8bbac7f10ecc786703fe0a6a4b952189f908707980ba8f3c8975a760962","impliedFormat":1},{"version":"5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","impliedFormat":1},{"version":"68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","impliedFormat":1},{"version":"5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4","impliedFormat":1},{"version":"feecb1be483ed332fad555aff858affd90a48ab19ba7272ee084704eb7167569","impliedFormat":1},{"version":"ee7bad0c15b58988daa84371e0b89d313b762ab83cb5b31b8a2d1162e8eb41c2","impliedFormat":1},{"version":"27bdc30a0e32783366a5abeda841bc22757c1797de8681bbe81fbc735eeb1c10","impliedFormat":1},{"version":"8fd575e12870e9944c7e1d62e1f5a73fcf23dd8d3a321f2a2c74c20d022283fe","impliedFormat":1},{"version":"8bf8b5e44e3c9c36f98e1007e8b7018c0f38d8adc07aecef42f5200114547c70","impliedFormat":1},{"version":"092c2bfe125ce69dbb1223c85d68d4d2397d7d8411867b5cc03cec902c233763","affectsGlobalScope":true,"impliedFormat":1},{"version":"c57796738e7f83dbc4b8e65132f11a377649c00dd3eee333f672b8f0a6bea671","affectsGlobalScope":true,"impliedFormat":1},{"version":"dc2df20b1bcdc8c2d34af4926e2c3ab15ffe1160a63e58b7e09833f616efff44","affectsGlobalScope":true,"impliedFormat":1},{"version":"515d0b7b9bea2e31ea4ec968e9edd2c39d3eebf4a2d5cbd04e88639819ae3b71","affectsGlobalScope":true,"impliedFormat":1},{"version":"0559b1f683ac7505ae451f9a96ce4c3c92bdc71411651ca6ddb0e88baaaad6a3","affectsGlobalScope":true,"impliedFormat":1},{"version":"0dc1e7ceda9b8b9b455c3a2d67b0412feab00bd2f66656cd8850e8831b08b537","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true,"impliedFormat":1},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ff2a353abf8a80ee399af572debb8faab2d33ad38c4b4474cff7f26e7653b8d","affectsGlobalScope":true,"impliedFormat":1},{"version":"936e80ad36a2ee83fc3caf008e7c4c5afe45b3cf3d5c24408f039c1d47bdc1df","affectsGlobalScope":true,"impliedFormat":1},{"version":"d15bea3d62cbbdb9797079416b8ac375ae99162a7fba5de2c6c505446486ac0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"68d18b664c9d32a7336a70235958b8997ebc1c3b8505f4f1ae2b7e7753b87618","affectsGlobalScope":true,"impliedFormat":1},{"version":"eb3d66c8327153d8fa7dd03f9c58d351107fe824c79e9b56b462935176cdf12a","affectsGlobalScope":true,"impliedFormat":1},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true,"impliedFormat":1},{"version":"69ab18c3b76cd9b1be3d188eaf8bba06112ebbe2f47f6c322b5105a6fbc45a2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"fef8cfad2e2dc5f5b3d97a6f4f2e92848eb1b88e897bb7318cef0e2820bceaab","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true,"impliedFormat":1},{"version":"4de680d5bb41c17f7f68e0419412ca23c98d5749dcaaea1896172f06435891fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"954296b30da6d508a104a3a0b5d96b76495c709785c1d11610908e63481ee667","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac9538681b19688c8eae65811b329d3744af679e0bdfa5d842d0e32524c73e1c","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a969edff4bd52585473d24995c5ef223f6652d6ef46193309b3921d65dd4376","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e9fbd7030c440b33d021da145d3232984c8bb7916f277e8ffd3dc2e3eae2bdb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811ec78f7fefcabbda4bfa93b3eb67d9ae166ef95f9bff989d964061cbf81a0c","affectsGlobalScope":true,"impliedFormat":1},{"version":"717937616a17072082152a2ef351cb51f98802fb4b2fdabd32399843875974ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"d7e7d9b7b50e5f22c915b525acc5a49a7a6584cf8f62d0569e557c5cfc4b2ac2","affectsGlobalScope":true,"impliedFormat":1},{"version":"71c37f4c9543f31dfced6c7840e068c5a5aacb7b89111a4364b1d5276b852557","affectsGlobalScope":true,"impliedFormat":1},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true,"impliedFormat":1},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true,"impliedFormat":1},{"version":"74f7fa2d027d5b33eb0471c8e82a6c87216223181ec31247c357a3e8e2fddc5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"d6d7ae4d1f1f3772e2a3cde568ed08991a8ae34a080ff1151af28b7f798e22ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"063600664504610fe3e99b717a1223f8b1900087fab0b4cad1496a114744f8df","affectsGlobalScope":true,"impliedFormat":1},{"version":"934019d7e3c81950f9a8426d093458b65d5aff2c7c1511233c0fd5b941e608ab","affectsGlobalScope":true,"impliedFormat":1},{"version":"52ada8e0b6e0482b728070b7639ee42e83a9b1c22d205992756fe020fd9f4a47","affectsGlobalScope":true,"impliedFormat":1},{"version":"3bdefe1bfd4d6dee0e26f928f93ccc128f1b64d5d501ff4a8cf3c6371200e5e6","affectsGlobalScope":true,"impliedFormat":1},{"version":"59fb2c069260b4ba00b5643b907ef5d5341b167e7d1dbf58dfd895658bda2867","affectsGlobalScope":true,"impliedFormat":1},{"version":"639e512c0dfc3fad96a84caad71b8834d66329a1f28dc95e3946c9b58176c73a","affectsGlobalScope":true,"impliedFormat":1},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true,"impliedFormat":1},{"version":"af3dd424cf267428f30ccfc376f47a2c0114546b55c44d8c0f1d57d841e28d74","affectsGlobalScope":true,"impliedFormat":1},{"version":"995c005ab91a498455ea8dfb63aa9f83fa2ea793c3d8aa344be4a1678d06d399","affectsGlobalScope":true,"impliedFormat":1},{"version":"959d36cddf5e7d572a65045b876f2956c973a586da58e5d26cde519184fd9b8a","affectsGlobalScope":true,"impliedFormat":1},{"version":"965f36eae237dd74e6cca203a43e9ca801ce38824ead814728a2807b1910117d","affectsGlobalScope":true,"impliedFormat":1},{"version":"3925a6c820dcb1a06506c90b1577db1fdbf7705d65b62b99dce4be75c637e26b","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a3d63ef2b853447ec4f749d3f368ce642264246e02911fcb1590d8c161b8005","affectsGlobalScope":true,"impliedFormat":1},{"version":"b5ce7a470bc3628408429040c4e3a53a27755022a32fd05e2cb694e7015386c7","affectsGlobalScope":true,"impliedFormat":1},{"version":"8444af78980e3b20b49324f4a16ba35024fef3ee069a0eb67616ea6ca821c47a","affectsGlobalScope":true,"impliedFormat":1},{"version":"3287d9d085fbd618c3971944b65b4be57859f5415f495b33a6adc994edd2f004","affectsGlobalScope":true,"impliedFormat":1},{"version":"b4b67b1a91182421f5df999988c690f14d813b9850b40acd06ed44691f6727ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"df83c2a6c73228b625b0beb6669c7ee2a09c914637e2d35170723ad49c0f5cd4","affectsGlobalScope":true,"impliedFormat":1},{"version":"436aaf437562f276ec2ddbee2f2cdedac7664c1e4c1d2c36839ddd582eeb3d0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e3c06ea092138bf9fa5e874a1fdbc9d54805d074bee1de31b99a11e2fec239d","affectsGlobalScope":true,"impliedFormat":1},{"version":"87dc0f382502f5bbce5129bdc0aea21e19a3abbc19259e0b43ae038a9fc4e326","affectsGlobalScope":true,"impliedFormat":1},{"version":"b1cb28af0c891c8c96b2d6b7be76bd394fddcfdb4709a20ba05a7c1605eea0f9","affectsGlobalScope":true,"impliedFormat":1},{"version":"2fef54945a13095fdb9b84f705f2b5994597640c46afeb2ce78352fab4cb3279","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac77cb3e8c6d3565793eb90a8373ee8033146315a3dbead3bde8db5eaf5e5ec6","affectsGlobalScope":true,"impliedFormat":1},{"version":"56e4ed5aab5f5920980066a9409bfaf53e6d21d3f8d020c17e4de584d29600ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ece9f17b3866cc077099c73f4983bddbcb1dc7ddb943227f1ec070f529dedd1","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a6282c8827e4b9a95f4bf4f5c205673ada31b982f50572d27103df8ceb8013c","affectsGlobalScope":true,"impliedFormat":1},{"version":"1c9319a09485199c1f7b0498f2988d6d2249793ef67edda49d1e584746be9032","affectsGlobalScope":true,"impliedFormat":1},{"version":"e3a2a0cee0f03ffdde24d89660eba2685bfbdeae955a6c67e8c4c9fd28928eeb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811c71eee4aa0ac5f7adf713323a5c41b0cf6c4e17367a34fbce379e12bbf0a4","affectsGlobalScope":true,"impliedFormat":1},{"version":"51ad4c928303041605b4d7ae32e0c1ee387d43a24cd6f1ebf4a2699e1076d4fa","affectsGlobalScope":true,"impliedFormat":1},{"version":"60037901da1a425516449b9a20073aa03386cce92f7a1fd902d7602be3a7c2e9","affectsGlobalScope":true,"impliedFormat":1},{"version":"d4b1d2c51d058fc21ec2629fff7a76249dec2e36e12960ea056e3ef89174080f","affectsGlobalScope":true,"impliedFormat":1},{"version":"22adec94ef7047a6c9d1af3cb96be87a335908bf9ef386ae9fd50eeb37f44c47","affectsGlobalScope":true,"impliedFormat":1},{"version":"4245fee526a7d1754529d19227ecbf3be066ff79ebb6a380d78e41648f2f224d","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e7f8264d0fb4c5339605a15daadb037bf238c10b654bb3eee14208f860a32ea","affectsGlobalScope":true,"impliedFormat":1},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true,"impliedFormat":1},{"version":"4a882ffbb4ed09d9b7734f784aebb1dfe488d63725c40759165c5d9c657ca029","impliedFormat":1},{"version":"36a2e4c9a67439aca5f91bb304611d5ae6e20d420503e96c230cf8fcdc948d94","affectsGlobalScope":true,"impliedFormat":1},{"version":"8a8eb4ebffd85e589a1cc7c178e291626c359543403d58c9cd22b81fab5b1fb9","impliedFormat":1},{"version":"247a952efd811d780e5630f8cfd76f495196f5fa74f6f0fee39ac8ba4a3c9800","impliedFormat":1},{"version":"aa17748c522bd586f8712b1a308ea23af59c309b2fd278f6d4f406647c72e659","affectsGlobalScope":true,"impliedFormat":1},{"version":"42c169fb8c2d42f4f668c624a9a11e719d5d07dacbebb63cbcf7ef365b0a75b3","impliedFormat":1},"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",{"version":"5487b97cfa28b26b4a9ef0770f872bdbebd4c46124858de00f242c3eed7519f4","impliedFormat":1},{"version":"c2869c4f2f79fd2d03278a68ce7c061a5a8f4aed59efb655e25fe502e3e471d5","impliedFormat":1},{"version":"b8fe42dbf4b0efba2eb4dbfb2b95a3712676717ff8469767dc439e75d0c1a3b6","impliedFormat":1},{"version":"8485b6da53ec35637d072e516631d25dae53984500de70a6989058f24354666f","impliedFormat":1},{"version":"ebe80346928736532e4a822154eb77f57ef3389dbe2b3ba4e571366a15448ef2","impliedFormat":1},{"version":"83306c97a4643d78420f082547ea0d488a0d134c922c8e65fc0b4f08ef66d92b","impliedFormat":1},{"version":"f672c876c1a04a223cf2023b3d91e8a52bb1544c576b81bf64a8fec82be9969c","impliedFormat":1},{"version":"98a9cc18f661d28e6bd31c436e1984f3980f35e0f0aa9cf795c54f8ccb667ffe","impliedFormat":1},{"version":"c76b0c5727302341d0bdfa2cc2cee4b19ff185b554edb6e8543f0661d8487116","impliedFormat":1},{"version":"dccd26a5c85325a011aff40f401e0892bd0688d44132ba79e803c67e68fffea5","impliedFormat":1},{"version":"f5ef066942e4f0bd98200aa6a6694b831e73200c9b3ade77ad0aa2409e8fe1b1","impliedFormat":1},{"version":"b9e99cd94f4166a245f5158f7286c05406e2a4c694619bceb7a4f3519d1d768e","impliedFormat":1},{"version":"5568d7c32e5cf5f35e092649f4e5e168c3114c800b1d7545b7ae5e0415704802","impliedFormat":1},{"version":"a3b8ebbff16895842be69c31658dd2dae0c33c3b1ac650cbccd60900aca1238c","impliedFormat":1},{"version":"9264e6f9617bbb4894738f198790191a6ec58e1fa997ed4c3bdbff93c09ef918","impliedFormat":1},{"version":"124ea88466db219a0ed430fb735a4ecd824bdad9781293fe66e94eeb8f4055f7","impliedFormat":1},{"version":"2e812554c576fa240ffaa71d0ca5259181cfb00cab70c5c1f78eb7d8e3330e44","impliedFormat":1},{"version":"fd225bb43195e90690cd17cbc7fc416b8ac4242671c8d9ea47b1f5100386711c","impliedFormat":1},{"version":"b89c26a54fb97eed00f94fe3f6791a5bcf62adf3d8f9d1e1c5cfcf3517c79e93","impliedFormat":1},{"version":"a09dee614aa1423e888a527e4bf11ada691427416a3af8911b5b5f9ecf21ebc7","impliedFormat":1},{"version":"c63e1447746a359ffcb3ea45786b3af5523cf34d36df470bfaddc21ad747eefa","impliedFormat":1},{"version":"7e4b99ecd027a7cbd707e9874129f0bd76b4bd6a17a94d960502b71dc40a3bab","impliedFormat":1},{"version":"39b8a37ded69edea6a8accdf58b133e8cde30b0b782aced256ecbd16905a5d91","impliedFormat":1},{"version":"52983c713dac09b4ba96153f24b852bbdfa470929965b93a538126e2b50bad1b","impliedFormat":1},{"version":"84d5257c97dc9f8cb1500cec23a011744c7a13f0dd72133705d2af838ee4564f","impliedFormat":1},{"version":"0856233bdf7a3f9f2d8da63236e81b759a409b7e371e94b57619401e80703a0f","impliedFormat":1},{"version":"779007337a0ce855eaef8fae082043a48ea00ccbafc5fd182bae4d94a1ea8d46","impliedFormat":1},{"version":"3f20270c0b9e2dcd1265e22f4060f0bd64d21397aa8dce5c071fec232c0d1dba","impliedFormat":1},{"version":"68abc9d3fdc5cd30079d5aa79aee3fe335e97fd2465a6e0d97a911ed5f126be7","impliedFormat":1},{"version":"bfd78208936ad2643e815006c8e00f5a116731d41d2764fda8a9467f5eb255f0","impliedFormat":1},{"version":"15377b7cd10daa2c5f141cbdb6e6b1b5cf2293c9508eec8096e72e61c34ebd08","impliedFormat":1},{"version":"666eb4a8473b9620466e13f59470a90bbdd907e17b64005ceaf6b03a26cd0eed","impliedFormat":1},{"version":"8f01a3f96136b81ab57882f99b1153162f23601bb4fc4e698e742d46255757f1","impliedFormat":1},{"version":"114ea62a1bbdad2497709eb102870eb27acb298f06c8e3963662305ca2ed970b","impliedFormat":1},{"version":"ac892647a4874d9293cbb86c16a22a199a0c11805d4a0b72b7cfc7c17e73b65c","impliedFormat":1},{"version":"f52d2c00e4bf0da52873694d895aa42949255ce23545282416fadb4d9ebed6bd","impliedFormat":1},{"version":"91312ddbe6a7aa9060cf4137367ce912564388a4eeabe6f903a81f9bf4d78c76","impliedFormat":1},{"version":"5568d7c32e5cf5f35e092649f4e5e168c3114c800b1d7545b7ae5e0415704802","impliedFormat":1},{"version":"8954ec7b7db8a029e40fefb3af01f0e5c700fba8a702f0d9dcce221f7f4cd207","impliedFormat":1},{"version":"33ba37fbce1b78c28e860fb312b6284587b0224640bf8233cca69ebbb78b1629","impliedFormat":1},{"version":"ab4ec5d270e1b668b58c02984e3b108f28521d6c0e3ef5226306d1548e88f19b","impliedFormat":1},{"version":"ac5d2b8e7fd7b8a311841bbd185e6c3fffc8dd8c1e5aa1d8feba29b82143c1f3","impliedFormat":1},{"version":"62ba04b53cd345bed495e5201a7826432cc92a585250df75234cf5af0955c406","impliedFormat":1},{"version":"93cfb20661b6e888148813f15c85f7b0c88fac9f01ac8d42e28f2c9bfef44aa6","impliedFormat":1},{"version":"0b5d14c81c906ba9dd283d8fbb0d3c5cdf98339688fc4eed0b10ba2e07cef7b2","impliedFormat":1},{"version":"a09dee614aa1423e888a527e4bf11ada691427416a3af8911b5b5f9ecf21ebc7","impliedFormat":1},{"version":"9acb19b8be6cf416b6ad97c47d54e4cd1dfe1e4df7de2513668692be67be2213","impliedFormat":1},{"version":"d9ca3f9825c359810f4d5da8030e494b24ab8cd6ddfa7ed74095480aa48fe8e7","impliedFormat":1},{"version":"5568d7c32e5cf5f35e092649f4e5e168c3114c800b1d7545b7ae5e0415704802","impliedFormat":1},{"version":"c149ea06ee58817eec6c0190b559f0a6ef0173bf2bc870ee94c88a7265913a5e","impliedFormat":1},{"version":"d7c447aaf48a7dc6f2c05de54b6f69fef43d470db301a578563b6743a8af6c5b","impliedFormat":1},{"version":"9072df8167c523cb92a0ade4494b84ba744ed3b0779b690d6671a8febb130d6d","impliedFormat":1},{"version":"9146d34a054ad4e3b4c3f53f64d4c106e643f41209bf05b94d95b593bd6c76c0","impliedFormat":1},{"version":"ac26beae2bf3f2c845b7ffad4f55328ed160dd0f463a58372704fa7a6c27f793","impliedFormat":1},{"version":"3dba40834ea8ad5f08ec3b551553ae547030608f1460c92ecefef66acf9971e6","impliedFormat":1},{"version":"02e4fb209668b155c16cb98113e9c83e47bcd5fcf5035b5e2698ebdc95b746a3","impliedFormat":1},{"version":"281cc4fead96675e77eb85d6f5208a514500188b2fbda2935134da18c77ff5c9","impliedFormat":1},{"version":"5568d7c32e5cf5f35e092649f4e5e168c3114c800b1d7545b7ae5e0415704802","impliedFormat":1},{"version":"82c726b64759224520bb3c187cd3494f7731a9244573445d917122b22fa41f8b","impliedFormat":1},{"version":"5a476527f1a31455d2aa76d8187a53a06555293cb797460c193f0653c6a92f81","impliedFormat":1},{"version":"86c8920dfdfd2fcf21905d8104c33616504180585641f317c5631048fcfe4a03","impliedFormat":1},{"version":"4184b667a9e014f1a46865116c8b72e59686d36472504a563b7bc0bfb5da0a70","impliedFormat":1},{"version":"947b215236fa225e762a0ccade1202a510904e0ae1c2dab37c1ab54311005e99","impliedFormat":1},{"version":"a3fd2df2cf87e74ba90866b3c0db0f3cae37a810ff1bf4d651d7e1a5f16691cb","impliedFormat":1},{"version":"b56044abdb4c999f71dbd0c3ddd7070484c323465ce5d3c2dc8f856f929be25d","impliedFormat":1},{"version":"727950dfc1b8ea42553aa73d4070d302db0a72b6cba269e4ff330561f90e1ac1","impliedFormat":1},{"version":"8319050f75e283498e0a4be07509ea3dc5328868cfd075b24f4f00fa4d1e2e12","impliedFormat":1},{"version":"39c4884d89bf2b8ea56944dc1d4f68fd605b9731a542394d3f168149440daf3f","impliedFormat":1},{"version":"bb7e593672a1ba37acfeda32959bb875b0c1b99b42ac7d2d16ecd90112444ea3","impliedFormat":1},{"version":"d2870deb69d0c7b772dfb9b5bc98322d65a2ae0b4466e6661ea57f0a41acdd17","impliedFormat":1},{"version":"29e6ba9418652c0dae9967a701e1675436274f1c2188eb86d75788916eea78d8","impliedFormat":1},{"version":"032f2579c9feb398784fef78e80a9b12acbcde0787ef67280f5d6db1fc05eea1","impliedFormat":1},{"version":"d1d18efda8252075a82a6d3e6c373e144322b1bf1a018ec831aec2a34cfc6384","impliedFormat":1},{"version":"e34554a2fdf20912ecb78d26e6d235561e0d579c7bac05b88b61dc69728ce636","impliedFormat":1},{"version":"e3f4430a9aa0d8cbb580de46fdc9eae49137275a62e5129e916d3aa03f1f7c81","impliedFormat":1},{"version":"8fdf0d718f6a6cc522c6b9d187fb5ccbdbbc4f36d5c5f53cf868ddc8cb619fc0","impliedFormat":1},{"version":"18e13513020f10291752f64a8b14556bdcec2af968e5ddbafa42f0c81c669718","impliedFormat":1},{"version":"d08347843fa76bb14ba0e003803278711515b04c2f0ab0bf0a14baa89acfe3b9","impliedFormat":1},{"version":"14a56bbcee52b698f1907c3d9428b2d9bef8ea611ddd6f5f76af3f601d9c6c6d","impliedFormat":1},{"version":"540e084b06df30a4e27b271bc2163c8f88b3d181c18497173f8ab3c6218107b5","impliedFormat":1},{"version":"75739fdfe4274aa1603b8c3e08ab21d2465ba4fa598912aa447590af2ebe35a7","impliedFormat":1},{"version":"60387bc1f3a8ac59f3cecd4e37ae632852982b9d0a37849b113502f96abad4dd","impliedFormat":1},{"version":"448088258817dcfac1af44820f02268d3a733fba3165a4df27eeadbec2416064","impliedFormat":1},{"version":"f838227553bed5ea4557c9eb3a3782ac2e9395c01918223f087f0b760eb726b8","impliedFormat":1},{"version":"981bda3857b717fb54aa64d28bb60afc509ea4c27aa32f140a30f91b75426abf","impliedFormat":1},{"version":"5dc248f7d6c401a87b4468922af2cedb4efa98f0ac10f7f0547cf13988f99e48","impliedFormat":1},{"version":"31ed79e3763b49014680e3bc871b776311303d73af813638f59d89ad3b0cf50e","impliedFormat":1},{"version":"939bfbbc861cbe104793567d5505609012a0ce84901a9c044e282d180981982d","impliedFormat":1},{"version":"88069fbc0eaf70d82d1439504a0cada34250d761b65de8ff350a778e3fb3063d","impliedFormat":1},{"version":"da592d0fdb1a2897803ccb0f949320dfeb76dad033fec0f8d5d6933fddfa0f4b","impliedFormat":1},{"version":"7be3fe0dd8fd7e3a6296c2a0b9e017b8dec496e461d46d6ba66925d8b0d778cf","impliedFormat":1},{"version":"7b32a09d43a93680b366cc7a7637c884e1dd817e3939f413c5aa0cdad914afeb","impliedFormat":1},{"version":"1a5a65d70494b82429b5bd78ce6cdf73037b10e7342ecd825a660b11ff72d630","impliedFormat":1},{"version":"5a0c4a6099823aba7dafd1d73bd3aa09084807e3696a24400a3ea3ba3755c987","impliedFormat":1},{"version":"d8a711cb6e0725f842cbe33ba8aa2bc7bdda76431d86e471a7ff7aea7163f323","impliedFormat":1},{"version":"f339feba19bec1ecf5861b9bf95290b5f97a33f996e7fc848975eb18b32c8f5f","impliedFormat":1},{"version":"3b0452b59d5f6643cbe359791d6436d984fc96cf26dedc8d00ec97c617458122","impliedFormat":1},{"version":"f95cad3d309ede51f10e1aa0c2c7821a41193fa10de7712c8166663225aec4d0","impliedFormat":1},{"version":"cdbbb6fa13c0f825cb905b44ddb851e062040d1f39819991aeb13ef063470fbb","impliedFormat":1},{"version":"4381966f54c1fa6be438a3da9638ad10f7bc929a40a9f19a860728f0ec2e6f82","impliedFormat":1},{"version":"a8dd5ff55b15c2966589edf335832b73f09e1a4dd6c3d5e75a99384f3ff364f4","impliedFormat":1},{"version":"145ed100d0e72ac22eb687fda563b24a5615b939f93a0418aac7f3b5fbce73b6","impliedFormat":1},{"version":"9bf18fada27d27d8e7651eee691a12e35d14a4395bc3fe322418216ea04682c6","impliedFormat":1},{"version":"5568d7c32e5cf5f35e092649f4e5e168c3114c800b1d7545b7ae5e0415704802","impliedFormat":1},{"version":"a50a2a9a52dd91015ec199535c494a2a368023979446d0fa2e602f04674360a3","impliedFormat":1},{"version":"2c2a2cd1a1a66abf9d5780332a57d12e5e7d6e43c73be623d99f6b3c1e6eb363","impliedFormat":1},{"version":"1667c652b307c2827c64ce3bbb2e635345c55a92c3d4abd7253e56869f5a7e61","impliedFormat":1},{"version":"7d98f81bc8af2f8bc430326b639fcda09cb509308cfeb8f86ce236fdbe9799c3","impliedFormat":1},{"version":"a76b5e27202b6feb8970aef217cba4d2328675b0c0201d242abb51693be83893","impliedFormat":1},{"version":"0f6ccb7b3426efd6222202f72b7c651f62ef6c9f0a77f6cc87db1bc4e4b48805","impliedFormat":1},{"version":"7186f05a8dfdf3169ff00d11ee20d54aa9ba8994271767cda47a1f36d3be89fd","impliedFormat":1},{"version":"7be1d0dbcff8a34b274691fcdf0cbb015f765b3e0cd46751405236510cf5fa41","impliedFormat":1},{"version":"ef90eb477def9a6ff3883e7a87d1fa24d65a76814dd7d86df4fa4ee73d065f62","impliedFormat":1},{"version":"b73e70161031064cf8e2f078d811a4f6a87178ff8ab90a7b95366386bbef20b5","impliedFormat":1},{"version":"abe84361596dadaeec62ab34380721d4b7bfca8b2884e9be2323e22316c36d52","impliedFormat":1},{"version":"72a015948cce8a3e8b79b56de8e8d39edbc69adc13d854d0cda90bb8f5e7f732","impliedFormat":1},{"version":"b5147bbd2e4dbb97a9e2bf0a89e22734f53c68c514b79c7535472b536c497a03","impliedFormat":1},{"version":"5568d7c32e5cf5f35e092649f4e5e168c3114c800b1d7545b7ae5e0415704802","impliedFormat":1},{"version":"01025cde3d0388f2942959e9ef24e8126ea755f55f6a17eb2100a3b44ea71422","impliedFormat":1},{"version":"0aaa84e0df52dc8b5859d24502a184d3279e6ea85163958f08155e4dbb3d4d45","impliedFormat":1},{"version":"45182be842c1c6809296c46f24fd45a1fd6298f7b4b84a414436fd2ad5827d4e","impliedFormat":1},{"version":"e5dfe656913c2450a17c62988011b9d821edb4fea05dbd602675e3aa7c9d73f6","impliedFormat":1},{"version":"5c75b213bf38325e9c299f72affa809deb59a0dc97e4bf3fdc63ccbdc20653e1","impliedFormat":1},{"version":"7fb9b305032813f96ccb246951989ab24d8ef48836fdcfa38dd047c61a7ed30e","impliedFormat":1},{"version":"ae3992dabaca90ca1558568800318eeeb32ec95f88a943642227373e2244d38c","impliedFormat":1},{"version":"d4ae12fd53be33b90aeda73d73f644337b7623fb0e60b69ad32c526dbf785a5a","impliedFormat":1},{"version":"da7476aaf1035c16565fb8402a2704cc8c8e3d7cb39180120bb3711525d0ab8b","impliedFormat":1},{"version":"99030f98187f417f3b9ef1e4110e7c9ca1ab6ed98b6e12e6130d168f943be079","impliedFormat":1},{"version":"d78c03d2fb5ac98e156dac9e246ac9402fd3ae7b8054838e865f308417bc9e8e","impliedFormat":1},{"version":"b73c573cea63c32054fcac34f3b24c9e4d913526a5ee1409d8b0f3c0533d42ad","impliedFormat":1},{"version":"0020080bb907fe6440979fad8c50f3525d1306d3971ec799295dad5bb183e0f9","impliedFormat":1},{"version":"68f96a3f108f74e8edcc2fcdfb3879c7b692be557f179a0fc3c3bcbb100d0282","impliedFormat":1},{"version":"fa8b0784e57a825ac298e89a5c43ac4ec88b8dbde74f9bef87c38f891fc2e162","impliedFormat":1},{"version":"738634e0df0b2c4a4ba1b2969e3de2a7661b1f19d0001eda6fa53e4ca6e0b788","impliedFormat":1},{"version":"cf3fb5afd21918cceb0f7ab76a046c475a4a086264a7ab0b279804224124e578","impliedFormat":1},{"version":"3835f8d92ad0699690cf572ad0da8aa3bfa5cc1c66fbd2609c52a02b9da828dc","impliedFormat":1},{"version":"6db928ecb8b9450aecc2a5ecdead68fa8b7a72130008c77e7524dfa7ef6c7002","impliedFormat":1},{"version":"4083e6d84bfe72b0835b600185c7b7ce321da3d6053f866859185eefc161e7a0","impliedFormat":1},{"version":"b883e245dc30c73b655ffe175712cac82981fc999d6284685f0ed7c1dac8aa6f","impliedFormat":1},{"version":"626e3504b81883fa94578c2a97eff345fadc5eae17a57c39f585655eef5b8272","impliedFormat":1},{"version":"e9a15eeba29ceb0ee109dd5e0282d2877d8165d87251f2ea9741a82685a25c61","impliedFormat":1},{"version":"c6cb06cc021d9149301f3c51762a387f9d7571feed74273b157d934c56857fac","impliedFormat":1},{"version":"cd7c133395a1c72e7c9e546f62292f839819f50a8aa46050f8588b63ef56df88","impliedFormat":1},{"version":"196f5f74208ce4accea017450ed2abc9ce4ab13c29a9ea543db4c2d715a19183","impliedFormat":1},{"version":"4687c961ab2e3107379f139d22932253afb7dd52e75a18890e70d4a376cdf5d9","impliedFormat":1},{"version":"ae8cfe2e3bdef3705fc294d07869a0ab8a52d9b623d1cc0482b6fc2be262b015","impliedFormat":1},{"version":"94c8e9c00244bbf1c868ca526b12b4db1fab144e3f5e18af3591b5b471854157","impliedFormat":1},{"version":"827d576995f67a6205c0f048ae32f6a1cf7bda9a7a76917ab286ef11d7987fd7","impliedFormat":1},{"version":"cb5dc83310a61d2bb351ddcdcaa6ec1cf60cc965d26ce6f156a28b4062e96ab2","impliedFormat":1},{"version":"0091cb2456a823e123fe76faa8b94dea81db421770d9a9c9ade1b111abe0fcd1","impliedFormat":1},{"version":"034d811fd7fb2262ad35b21df0ecab14fdd513e25dbf563572068e3f083957d9","impliedFormat":1},{"version":"298bcc906dd21d62b56731f9233795cd11d88e062329f5df7cdb4e499207cdd4","impliedFormat":1},{"version":"f7e64be58c24f2f0b7116bed8f8c17e6543ddcdc1f46861d5c54217b4a47d731","impliedFormat":1},{"version":"966394e0405e675ca1282edbfa5140df86cb6dc025e0f957985f059fe4b9d5d6","impliedFormat":1},{"version":"b0587deb3f251b7ad289240c54b7c41161bb6488807d1f713e0a14c540cbcaee","impliedFormat":1},{"version":"4254aab77d0092cab52b34c2e0ab235f24f82a5e557f11d5409ae02213386e29","impliedFormat":1},{"version":"19db45929fad543b26b12504ee4e3ff7d9a8bddc1fc3ed39723c2259e3a4590f","impliedFormat":1},{"version":"b21934bebe4cd01c02953ab8d17be4d33d69057afdb5469be3956e84a09a8d99","impliedFormat":1},{"version":"b2b734c414d440c92a17fd409fa8dac89f425031a6fc7843bac765c6c174d1ca","impliedFormat":1},{"version":"239f39e8ad95065f5188a7acd8dbefbbbf94d9e00c460ffdc331e24bc1f63a54","impliedFormat":1},{"version":"d44f78893cb79e00e16a028e3023a65c1f2968352378e8e323f8c8f88b8da495","impliedFormat":1},{"version":"32afc9daae92391cb4efeb0d2dac779dc0fb17c69be0eb171fd5ed7f7908eeb4","impliedFormat":1},{"version":"b835c6e093ad9cda87d376c248735f7e4081f64d304b7c54a688f1276875cbf0","impliedFormat":1},{"version":"a9eabe1d0b20e967a18758a77884fbd61b897d72a57ddd9bf7ea6ef1a3f4514b","impliedFormat":1},{"version":"64c5059e7d7a80fe99d7dad639f3ba765f8d5b42c5b265275d7cd68f8426be75","impliedFormat":1},{"version":"05dc1970dc02c54db14d23ff7a30af00efbd7735313aa8af45c4fd4f5c3d3a33","impliedFormat":1},{"version":"a0caf07fe750954ad4cf079c5cf036be2191a758c2700424085ffde6af60d185","impliedFormat":1},{"version":"1ea59d0d71022de8ea1c98a3f88d452ad5701c7f85e74ddaa0b3b9a34ed0e81c","impliedFormat":1},{"version":"eab89b3aa37e9e48b2679f4abe685d56ac371daa8fbe68526c6b0c914eb28474","impliedFormat":1},{"version":"debc18aa3dba1f28f19b3cc632a0c538288b09962301d901b074903b3c09ec62","impliedFormat":1},{"version":"64456bf67e3e27ee199ebf28b90105f293c479dcdbb1720c041f9f54b2447f67","impliedFormat":1},{"version":"c983d19453192b5db84646c85f1bc4a5cbc6d856bf91df190e93c49c302ebe36","impliedFormat":1},{"version":"6b75f3cb3254ed2ebde6f7c9487711bd498406d86310033f5b705655016d3086","impliedFormat":1},{"version":"8155d7700604f77d1273a0de29d912c8cdfc531c03b18582764c6c7038ff1c42","impliedFormat":1},{"version":"03258b5d794eb03434318b909c0c6a8b7cd031fc33207799b0134e3c3da81532","impliedFormat":1},{"version":"25fa594d7e17d731fd20195af7569bf71e087ec5b724c7cbd406777f37b601b1","impliedFormat":1},{"version":"f357d723890ac3267efde52d144eff7a52311eddef5e5fc8fcea5650bef0d785","impliedFormat":1},"5d6d4f8ee5a85e90a7b04737b1fd2bc72b2a041d216152013d5c00a186ab681b","11194ddb8eba1fbdf82deefd6decabc03419c36c4996998407ed225e90317411",{"version":"16e9731ed48d605f85e6cc674d0c02c40a55af94712a7efce7b7a94f1f73f2a7","impliedFormat":1},{"version":"5e379df3d61561c2ed7789b5995b9ba2143bbba21a905e2381e16efe7d1fa424","impliedFormat":1},{"version":"4967529644e391115ca5592184d4b63980569adf60ee685f968fd59ab1557188","impliedFormat":1},{"version":"5657303e23d101f6a111507b6d4b1dc410d290781227109c0d8fb23a13fec2c7","impliedFormat":1},{"version":"75658069e9e161c850e43235d5d4723278875fc9f0dbe2c33a3bfe766b2144f0","impliedFormat":1},{"version":"62d686b226dbd48728c3f4fd9da3e00f557bd46ae6309e7f1cb580e80719f9db","impliedFormat":1},{"version":"6473f6211926331f9282d681de4394ef79ba31ada3f7f796709bd22c669c92de","impliedFormat":1},{"version":"6bd2840cc43df36daff0e9d4fe54a69dc628865f34ef8b82995aad2df246ba51","impliedFormat":1},{"version":"3efd242947bcee3c1fa5c1dd4a60657c63fbee41ae2d15d9124b7167f76be07e","impliedFormat":1},{"version":"5a916b0b34823d78155aee81cb1f07bc6d7ef18b0724dea07d43fae4d388e69c","impliedFormat":1},{"version":"67c7c2751c0613f3fb6e2ff30c406bc29314997ae27f93d7d0dfa01f5e2e76c4","impliedFormat":1},{"version":"3a6763b96331c6c403f1865972c448d3d8be8cfa310ff391c6b0cc5b97f617a4","impliedFormat":1},{"version":"5695684cb098d4e453174fee4eaf0f754e0cf561ddbf242ef3ed94a6ecaf7443","impliedFormat":1},{"version":"c51775b4135a2e3118c017c074146f26975e160ca366b1f115a35dad52b69a60","impliedFormat":1},{"version":"9dc2ba999c784da232c0aa23af0d54ab73d36413531f977fdd3e597b0ef4ed95","impliedFormat":1},{"version":"eefeb19fd479b86b1bf375a4f16f65249f98a49625911e901b98cd454d3fbdfa","impliedFormat":1},{"version":"751ef481734eba0c88fb72ca3e1aa4ed35d13bb2a1912893d25e08af9ed50f02","impliedFormat":1},{"version":"913c60104f1e7b295c60466c2fcef99e38604aed64c9e479a418841085baa1aa","impliedFormat":1},{"version":"2d7b8eea2ef2b1fbde4f9ac077058ef8b7f56fcdf378243acb9864afd5cf6531","impliedFormat":1},{"version":"2e270467f9bb7d8d056a739bbefaf327d8f5a085e09b2d957cc33af1d63dc7f9","impliedFormat":1},{"version":"1962026ce36685070aa45b6beca78e97f0d5beb315b535ccf0753c93efdfe06b","impliedFormat":1},{"version":"e6ec4654e0ba6ab3c5fa64175101ca6c9701998af53291f303d8698b776030cd","impliedFormat":1},{"version":"1cdf77975bc0c45040d4d6f98a515359a2e9a97a0c6c347ad8231a49ceb3e9a2","impliedFormat":1},{"version":"79079ca43a2015d655f4fb15d207f375e5bfcc488b7a5a37987f380a50dd00c2","impliedFormat":1},"ab0476d8eca69ab2fc9c8795df596a6c67d433a5c20c2929264376775619696b",{"version":"78318debb1be4ce1f1c00a74c1c3ffa0a844c1989379b23b5e523d46b0e12ee1","signature":"f9f52ed38e10831bfd9c0870e9b26323e0e87f1417403a3a2310da82ff2c90d7"},{"version":"63633f5796c4cf53210ce75f02e5d6e81b88012f5c8832af32c35d0a8b75cdde","impliedFormat":1},"7641d4860b2272873c6def15838b5b5a89e6b4866584d33905688d7f1817a0ab",{"version":"70521b6ab0dcba37539e5303104f29b721bfb2940b2776da4cc818c07e1fefc1","affectsGlobalScope":true,"impliedFormat":1},{"version":"030e350db2525514580ed054f712ffb22d273e6bc7eddc1bb7eda1e0ba5d395e","affectsGlobalScope":true,"impliedFormat":1},{"version":"d153a11543fd884b596587ccd97aebbeed950b26933ee000f94009f1ab142848","affectsGlobalScope":true,"impliedFormat":1},{"version":"21d819c173c0cf7cc3ce57c3276e77fd9a8a01d35a06ad87158781515c9a438a","impliedFormat":1},{"version":"a79e62f1e20467e11a904399b8b18b18c0c6eea6b50c1168bf215356d5bebfaf","affectsGlobalScope":true,"impliedFormat":1},{"version":"d802f0e6b5188646d307f070d83512e8eb94651858de8a82d1e47f60fb6da4e2","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e9c23ba78aabc2e0a27033f18737a6df754067731e69dc5f52823957d60a4b6","impliedFormat":1},{"version":"5929864ce17fba74232584d90cb721a89b7ad277220627cc97054ba15a98ea8f","impliedFormat":1},{"version":"763fe0f42b3d79b440a9b6e51e9ba3f3f91352469c1e4b3b67bfa4ff6352f3f4","impliedFormat":1},{"version":"25c8056edf4314820382a5fdb4bb7816999acdcb929c8f75e3f39473b87e85bc","impliedFormat":1},{"version":"c464d66b20788266e5353b48dc4aa6bc0dc4a707276df1e7152ab0c9ae21fad8","impliedFormat":1},{"version":"78d0d27c130d35c60b5e5566c9f1e5be77caf39804636bc1a40133919a949f21","impliedFormat":1},{"version":"c6fd2c5a395f2432786c9cb8deb870b9b0e8ff7e22c029954fabdd692bff6195","impliedFormat":1},{"version":"1d6e127068ea8e104a912e42fc0a110e2aa5a66a356a917a163e8cf9a65e4a75","impliedFormat":1},{"version":"5ded6427296cdf3b9542de4471d2aa8d3983671d4cac0f4bf9c637208d1ced43","impliedFormat":1},{"version":"7f182617db458e98fc18dfb272d40aa2fff3a353c44a89b2c0ccb3937709bfb5","impliedFormat":1},{"version":"cadc8aced301244057c4e7e73fbcae534b0f5b12a37b150d80e5a45aa4bebcbd","impliedFormat":1},{"version":"385aab901643aa54e1c36f5ef3107913b10d1b5bb8cbcd933d4263b80a0d7f20","impliedFormat":1},{"version":"9670d44354bab9d9982eca21945686b5c24a3f893db73c0dae0fd74217a4c219","impliedFormat":1},{"version":"0b8a9268adaf4da35e7fa830c8981cfa22adbbe5b3f6f5ab91f6658899e657a7","impliedFormat":1},{"version":"11396ed8a44c02ab9798b7dca436009f866e8dae3c9c25e8c1fbc396880bf1bb","impliedFormat":1},{"version":"ba7bc87d01492633cb5a0e5da8a4a42a1c86270e7b3d2dea5d156828a84e4882","impliedFormat":1},{"version":"4893a895ea92c85345017a04ed427cbd6a1710453338df26881a6019432febdd","impliedFormat":1},{"version":"c21dc52e277bcfc75fac0436ccb75c204f9e1b3fa5e12729670910639f27343e","impliedFormat":1},{"version":"13f6f39e12b1518c6650bbb220c8985999020fe0f21d818e28f512b7771d00f9","impliedFormat":1},{"version":"9b5369969f6e7175740bf51223112ff209f94ba43ecd3bb09eefff9fd675624a","impliedFormat":1},{"version":"4fe9e626e7164748e8769bbf74b538e09607f07ed17c2f20af8d680ee49fc1da","impliedFormat":1},{"version":"24515859bc0b836719105bb6cc3d68255042a9f02a6022b3187948b204946bd2","impliedFormat":1},{"version":"ea0148f897b45a76544ae179784c95af1bd6721b8610af9ffa467a518a086a43","impliedFormat":1},{"version":"24c6a117721e606c9984335f71711877293a9651e44f59f3d21c1ea0856f9cc9","impliedFormat":1},{"version":"dd3273ead9fbde62a72949c97dbec2247ea08e0c6952e701a483d74ef92d6a17","impliedFormat":1},{"version":"405822be75ad3e4d162e07439bac80c6bcc6dbae1929e179cf467ec0b9ee4e2e","impliedFormat":1},{"version":"0db18c6e78ea846316c012478888f33c11ffadab9efd1cc8bcc12daded7a60b6","impliedFormat":1},{"version":"e61be3f894b41b7baa1fbd6a66893f2579bfad01d208b4ff61daef21493ef0a8","impliedFormat":1},{"version":"bd0532fd6556073727d28da0edfd1736417a3f9f394877b6d5ef6ad88fba1d1a","impliedFormat":1},{"version":"89167d696a849fce5ca508032aabfe901c0868f833a8625d5a9c6e861ef935d2","impliedFormat":1},{"version":"615ba88d0128ed16bf83ef8ccbb6aff05c3ee2db1cc0f89ab50a4939bfc1943f","impliedFormat":1},{"version":"a4d551dbf8746780194d550c88f26cf937caf8d56f102969a110cfaed4b06656","impliedFormat":1},{"version":"8bd86b8e8f6a6aa6c49b71e14c4ffe1211a0e97c80f08d2c8cc98838006e4b88","impliedFormat":1},{"version":"317e63deeb21ac07f3992f5b50cdca8338f10acd4fbb7257ebf56735bf52ab00","impliedFormat":1},{"version":"4732aec92b20fb28c5fe9ad99521fb59974289ed1e45aecb282616202184064f","impliedFormat":1},{"version":"2e85db9e6fd73cfa3d7f28e0ab6b55417ea18931423bd47b409a96e4a169e8e6","impliedFormat":1},{"version":"c46e079fe54c76f95c67fb89081b3e399da2c7d109e7dca8e4b58d83e332e605","impliedFormat":1},{"version":"bf67d53d168abc1298888693338cb82854bdb2e69ef83f8a0092093c2d562107","impliedFormat":1},{"version":"3b724a66c071d616203133f8d099a0cb881b0b43fd42e8621e611243c5f30cd6","affectsGlobalScope":true,"impliedFormat":1},{"version":"a38efe83ff77c34e0f418a806a01ca3910c02ee7d64212a59d59bca6c2c38fa1","impliedFormat":1},{"version":"7394959e5a741b185456e1ef5d64599c36c60a323207450991e7a42e08911419","impliedFormat":1},{"version":"3fe4022ba1e738034e38ad9afacbf0f1f16b458ed516326f5bf9e4a31e9be1dc","impliedFormat":1},{"version":"a957197054b074bcdf5555d26286e8461680c7c878040d0f4e2d5509a7524944","affectsGlobalScope":true,"impliedFormat":1},{"version":"4314c7a11517e221f7296b46547dbc4df047115b182f544d072bdccffa57fc72","impliedFormat":1},{"version":"e9b97d69510658d2f4199b7d384326b7c4053b9e6645f5c19e1c2a54ede427fc","impliedFormat":1},{"version":"c2510f124c0293ab80b1777c44d80f812b75612f297b9857406468c0f4dafe29","affectsGlobalScope":true,"impliedFormat":1},{"version":"5524481e56c48ff486f42926778c0a3cce1cc85dc46683b92b1271865bcf015a","impliedFormat":1},{"version":"f478f6f5902dc144c0d6d7bdc919c5177cac4d17a8ca8653c2daf6d7dc94317f","affectsGlobalScope":true,"impliedFormat":1},{"version":"19d5f8d3930e9f99aa2c36258bf95abbe5adf7e889e6181872d1cdba7c9a7dd5","impliedFormat":1},{"version":"b200675fd112ffef97c166d0341fb33f6e29e9f27660adde7868e95c5bc98beb","impliedFormat":1},{"version":"a6bf63d17324010ca1fbf0389cab83f93389bb0b9a01dc8a346d092f65b3605f","impliedFormat":1},{"version":"e009777bef4b023a999b2e5b9a136ff2cde37dc3f77c744a02840f05b18be8ff","impliedFormat":1},{"version":"1e0d1f8b0adfa0b0330e028c7941b5a98c08b600efe7f14d2d2a00854fb2f393","impliedFormat":1},{"version":"ee1ee365d88c4c6c0c0a5a5701d66ebc27ccd0bcfcfaa482c6e2e7fe7b98edf7","affectsGlobalScope":true,"impliedFormat":1},{"version":"88bc59b32d0d5b4e5d9632ac38edea23454057e643684c3c0b94511296f2998c","affectsGlobalScope":true,"impliedFormat":1},{"version":"a0a1dda070290b92da5a50113b73ecc4dd6bcbffad66e3c86503d483eafbadcf","impliedFormat":1},{"version":"59dcad36c4549175a25998f6a8b33c1df8e18df9c12ebad1dfb25af13fd4b1ce","impliedFormat":1},{"version":"9ba5b6a30cb7961b68ad4fb18dca148db151c2c23b8d0a260fc18b83399d19d3","impliedFormat":1},{"version":"3f3edb8e44e3b9df3b7ca3219ab539710b6a7f4fe16bd884d441af207e03cd57","impliedFormat":1},{"version":"528b62e4272e3ddfb50e8eed9e359dedea0a4d171c3eb8f337f4892aac37b24b","impliedFormat":1},{"version":"d71535813e39c23baa113bc4a29a0e187b87d1105ccc8c5a6ebaca38d9a9bff2","impliedFormat":1},{"version":"8cf7e92bdb2862c2d28ba4535c43dc599cfbc0025db5ed9973d9b708dcbe3d98","affectsGlobalScope":true,"impliedFormat":1},{"version":"8a410a7fa4baf13dd45c9bba6d71806027dc0e4e5027cdf74f36466ae9b240b7","impliedFormat":1},{"version":"b1b6ee0d012aeebe11d776a155d8979730440082797695fc8e2a5c326285678f","impliedFormat":1},{"version":"45875bcae57270aeb3ebc73a5e3fb4c7b9d91d6b045f107c1d8513c28ece71c0","impliedFormat":1},{"version":"1dc73f8854e5c4506131c4d95b3a6c24d0c80336d3758e95110f4c7b5cb16397","affectsGlobalScope":true,"impliedFormat":1},{"version":"636302a00dfd1f9fe6e8e91e4e9350c6518dcc8d51a474e4fc3a9ba07135100b","affectsGlobalScope":true,"impliedFormat":1},{"version":"3f16a7e4deafa527ed9995a772bb380eb7d3c2c0fd4ae178c5263ed18394db2c","impliedFormat":1},{"version":"933921f0bb0ec12ef45d1062a1fc0f27635318f4d294e4d99de9a5493e618ca2","impliedFormat":1},{"version":"71a0f3ad612c123b57239a7749770017ecfe6b66411488000aba83e4546fde25","impliedFormat":1},{"version":"8145e07aad6da5f23f2fcd8c8e4c5c13fb26ee986a79d03b0829b8fce152d8b2","impliedFormat":1},{"version":"e1120271ebbc9952fdc7b2dd3e145560e52e06956345e6fdf91d70ca4886464f","impliedFormat":1},{"version":"814118df420c4e38fe5ae1b9a3bafb6e9c2aa40838e528cde908381867be6466","impliedFormat":1},{"version":"e1ce1d622f1e561f6cdf246372ead3bbc07ce0342024d0e9c7caf3136f712698","impliedFormat":1},{"version":"c878f74b6d10b267f6075c51ac1d8becd15b4aa6a58f79c0cfe3b24908357f60","impliedFormat":1},{"version":"37ba7b45141a45ce6e80e66f2a96c8a5ab1bcef0fc2d0f56bb58df96ec67e972","impliedFormat":1},{"version":"125d792ec6c0c0f657d758055c494301cc5fdb327d9d9d5960b3f129aff76093","impliedFormat":1},{"version":"27e4532aaaa1665d0dd19023321e4dc12a35a741d6b8e1ca3517fcc2544e0efe","affectsGlobalScope":true,"impliedFormat":1},{"version":"2754d8221d77c7b382096651925eb476f1066b3348da4b73fe71ced7801edada","impliedFormat":1},{"version":"8c2ad42d5d1a2e8e6112625767f8794d9537f1247907378543106f7ba6c7df90","affectsGlobalScope":true,"impliedFormat":1},{"version":"f0be1b8078cd549d91f37c30c222c2a187ac1cf981d994fb476a1adc61387b14","affectsGlobalScope":true,"impliedFormat":1},{"version":"0aaed1d72199b01234152f7a60046bc947f1f37d78d182e9ae09c4289e06a592","impliedFormat":1},{"version":"98ffdf93dfdd206516971d28e3e473f417a5cfd41172e46b4ce45008f640588e","impliedFormat":1},{"version":"66ba1b2c3e3a3644a1011cd530fb444a96b1b2dfe2f5e837a002d41a1a799e60","impliedFormat":1},{"version":"7e514f5b852fdbc166b539fdd1f4e9114f29911592a5eb10a94bb3a13ccac3c4","impliedFormat":1},{"version":"7d6ff413e198d25639f9f01f16673e7df4e4bd2875a42455afd4ecc02ef156da","affectsGlobalScope":true,"impliedFormat":1},{"version":"12e8ce658dd17662d82fb0509d2057afc5e6ee30369a2e9e0957eff725b1f11d","affectsGlobalScope":true,"impliedFormat":1},{"version":"74736930d108365d7bbe740c7154706ccfb1b2a3855a897963ab3e5c07ecbf19","impliedFormat":1},{"version":"858f999b3e4a45a4e74766d43030941466460bf8768361d254234d5870480a53","impliedFormat":1},{"version":"ac5ed35e649cdd8143131964336ab9076937fa91802ec760b3ea63b59175c10a","impliedFormat":1},{"version":"63b05afa6121657f25e99e1519596b0826cda026f09372c9100dfe21417f4bd6","affectsGlobalScope":true,"impliedFormat":1},{"version":"3797dd6f4ea3dc15f356f8cdd3128bfa18122213b38a80d6c1f05d8e13cbdad8","impliedFormat":1},{"version":"ad90122e1cb599b3bc06a11710eb5489101be678f2920f2322b0ac3e195af78d","impliedFormat":1}],"root":[86,264,265,290,291,293],"options":{"allowJs":false,"allowSyntheticDefaultImports":true,"downlevelIteration":true,"esModuleInterop":true,"importHelpers":true,"jsx":4,"module":99,"noEmitOnError":false,"noImplicitAny":false,"noImplicitReturns":false,"noUnusedLocals":false,"noUnusedParameters":false,"outDir":"./dist","preserveConstEnums":true,"removeComments":false,"rootDir":"./src","skipLibCheck":true,"sourceMap":false,"strictNullChecks":true,"target":7,"useUnknownInCatchVariables":false},"referencedMap":[[261,1],[222,2],[262,2],[263,3],[260,2],[78,2],[79,2],[13,2],[15,2],[14,2],[2,2],[16,2],[17,2],[18,2],[19,2],[20,2],[21,2],[22,2],[23,2],[3,2],[24,2],[25,2],[4,2],[26,2],[30,2],[27,2],[28,2],[29,2],[31,2],[32,2],[33,2],[5,2],[34,2],[35,2],[36,2],[37,2],[6,2],[41,2],[38,2],[39,2],[40,2],[42,2],[7,2],[43,2],[48,2],[49,2],[44,2],[45,2],[46,2],[47,2],[8,2],[53,2],[50,2],[51,2],[52,2],[54,2],[9,2],[55,2],[56,2],[57,2],[59,2],[58,2],[60,2],[61,2],[10,2],[62,2],[63,2],[64,2],[11,2],[65,2],[66,2],[67,2],[68,2],[69,2],[1,2],[70,2],[71,2],[12,2],[75,2],[73,2],[77,2],[72,2],[76,2],[74,2],[265,4],[291,5],[264,6],[293,7],[86,2],[290,8],[115,9],[114,10],[112,11],[116,12],[131,13],[113,14],[133,15],[132,16],[128,17],[129,17],[130,2],[117,2],[120,18],[125,19],[126,20],[119,21],[127,22],[118,2],[124,23],[136,24],[140,25],[139,26],[141,27],[142,28],[137,29],[138,29],[134,11],[135,23],[188,30],[189,31],[123,32],[122,33],[121,2],[111,34],[109,35],[110,36],[108,2],[101,37],[102,37],[103,2],[100,38],[107,39],[106,40],[104,41],[105,41],[99,23],[91,42],[97,43],[93,2],[94,2],[92,44],[95,23],[87,2],[88,2],[98,45],[90,46],[96,47],[89,48],[257,49],[256,50],[259,51],[258,52],[192,53],[193,2],[201,54],[191,38],[194,38],[196,55],[198,56],[195,38],[199,57],[200,58],[197,38],[190,23],[206,59],[212,2],[213,2],[214,2],[221,60],[186,61],[187,2],[218,62],[202,61],[219,2],[203,63],[220,64],[210,65],[211,66],[216,67],[207,68],[208,69],[215,70],[217,71],[209,72],[205,2],[204,23],[151,73],[143,2],[185,74],[152,38],[153,38],[154,38],[155,38],[156,38],[157,38],[158,38],[159,38],[160,38],[161,38],[162,38],[163,38],[149,75],[164,38],[150,38],[165,38],[166,2],[167,38],[169,76],[170,38],[171,38],[172,38],[173,38],[147,38],[174,38],[175,38],[176,38],[177,38],[178,38],[148,38],[179,38],[180,38],[181,38],[168,38],[182,38],[183,38],[184,38],[145,38],[146,2],[144,23],[266,2],[339,77],[340,77],[341,78],[299,79],[342,80],[343,81],[344,82],[294,2],[297,83],[295,2],[296,2],[345,84],[346,85],[347,86],[348,87],[349,88],[350,89],[351,89],[353,90],[352,91],[354,92],[355,93],[356,94],[338,95],[298,2],[357,96],[358,97],[359,98],[392,99],[360,100],[361,101],[362,102],[316,103],[326,104],[315,103],[336,105],[307,106],[306,107],[335,108],[329,109],[334,110],[309,111],[323,112],[308,113],[332,114],[304,115],[303,108],[333,116],[305,117],[310,118],[311,2],[314,118],[301,2],[337,119],[327,120],[318,121],[319,122],[321,123],[317,124],[320,125],[330,108],[312,126],[313,127],[322,128],[302,129],[325,120],[324,118],[328,2],[331,130],[363,131],[364,132],[365,133],[366,134],[367,135],[368,136],[369,137],[370,137],[371,138],[372,2],[373,2],[374,139],[376,140],[375,141],[377,142],[378,143],[379,144],[380,145],[381,146],[382,147],[383,148],[384,149],[385,150],[386,151],[387,152],[388,153],[389,154],[390,155],[391,156],[83,2],[81,2],[84,157],[85,158],[300,2],[82,2],[292,2],[288,2],[289,159],[255,160],[224,161],[234,161],[225,161],[235,161],[226,161],[227,161],[242,161],[241,161],[243,161],[244,161],[236,161],[228,161],[237,161],[229,161],[238,161],[230,161],[232,161],[240,162],[233,161],[239,162],[245,162],[231,161],[246,161],[251,161],[252,161],[247,161],[223,2],[253,2],[249,161],[248,161],[250,161],[254,161],[269,163],[273,164],[284,165],[279,166],[283,167],[287,168],[286,166],[278,169],[270,170],[274,171],[272,172],[276,173],[271,172],[277,174],[281,167],[275,163],[282,163],[285,163],[280,175],[268,2],[267,2],[80,2]],"version":"5.8.3"} \ No newline at end of file diff --git a/js/atproto-oauth-client-react-native/tsconfig.json b/js/atproto-oauth-client-react-native/tsconfig.json new file mode 100644 index 00000000..e84b8178 --- /dev/null +++ b/js/atproto-oauth-client-react-native/tsconfig.json @@ -0,0 +1,4 @@ +{ + "include": [], + "references": [{ "path": "./tsconfig.build.json" }] +} diff --git a/js/docs/package.json b/js/docs/package.json index ccd904c3..24d51c91 100644 --- a/js/docs/package.json +++ b/js/docs/package.json @@ -5,7 +5,7 @@ "scripts": { "dev": "astro dev --host 0.0.0.0 --port 38082", "start": "astro dev --host 0.0.0.0 --port 38082", - "build": "astro build && rm -rf ../app/dist/docs && cp -r dist ../app/dist/docs", + "build": "astro build && rm -rf ../app/dist/docs && mkdir -p ../app/dist && cp -r dist ../app/dist/docs", "preview": "astro preview", "astro": "astro" }, diff --git a/js/docs/src/content/docs/lex-reference/account/place-stream-account-defs.md b/js/docs/src/content/docs/lex-reference/account/place-stream-account-defs.md new file mode 100644 index 00000000..6ebd69be --- /dev/null +++ b/js/docs/src/content/docs/lex-reference/account/place-stream-account-defs.md @@ -0,0 +1,43 @@ +--- +title: place.stream.account.defs +description: Reference for the place.stream.account.defs lexicon +--- + +**Lexicon Version:** 1 + +## Definitions + + + +### `loginResponse` + +**Type:** `object` + +**Properties:** + +| Name | Type | Req'd | Description | Constraints | +| ------------- | -------- | ----- | ----------- | ------------- | +| `redirectUrl` | `string` | ✅ | | Format: `uri` | + +--- + +## Lexicon Source + +```json +{ + "lexicon": 1, + "id": "place.stream.account.defs", + "defs": { + "loginResponse": { + "type": "object", + "required": ["redirectUrl"], + "properties": { + "redirectUrl": { + "type": "string", + "format": "uri" + } + } + } + } +} +``` diff --git a/js/docs/src/content/docs/lex-reference/account/place-stream-account-login.md b/js/docs/src/content/docs/lex-reference/account/place-stream-account-login.md new file mode 100644 index 00000000..58000b51 --- /dev/null +++ b/js/docs/src/content/docs/lex-reference/account/place-stream-account-login.md @@ -0,0 +1,74 @@ +--- +title: place.stream.account.login +description: Reference for the place.stream.account.login lexicon +--- + +**Lexicon Version:** 1 + +## Definitions + + + +### `main` + +**Type:** `procedure` + +Get a redirect URL for the login flow. + +**Parameters:** _(None defined)_ + +**Input:** + +- **Encoding:** `application/json` +- **Schema:** + +**Schema Type:** `object` + +| Name | Type | Req'd | Description | Constraints | +| ------------- | -------- | ----- | ------------------------------------------ | ----------- | +| `handleOrDID` | `string` | ✅ | The handle or DID of the account to login. | | + +**Output:** + +- **Encoding:** `application/json` +- **Schema:** + +**Schema Type:** +[`place.stream.account.defs#loginResponse`](/lex-reference/place-stream-account-defs#loginresponse) + +--- + +## Lexicon Source + +```json +{ + "lexicon": 1, + "id": "place.stream.account.login", + "defs": { + "main": { + "type": "procedure", + "description": "Get a redirect URL for the login flow.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["handleOrDID"], + "properties": { + "handleOrDID": { + "type": "string", + "description": "The handle or DID of the account to login." + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "ref", + "ref": "place.stream.account.defs#loginResponse" + } + } + } + } +} +``` diff --git a/lexicons/app/bsky/actor/getProfile.json b/lexicons/app/bsky/actor/getProfile.json new file mode 100644 index 00000000..15b0fcc2 --- /dev/null +++ b/lexicons/app/bsky/actor/getProfile.json @@ -0,0 +1,28 @@ +{ + "lexicon": 1, + "id": "app.bsky.actor.getProfile", + "defs": { + "main": { + "type": "query", + "description": "Get detailed profile view of an actor. Does not require auth, but contains relevant metadata with auth.", + "parameters": { + "type": "params", + "required": ["actor"], + "properties": { + "actor": { + "type": "string", + "format": "at-identifier", + "description": "Handle or DID of account to fetch profile of." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "ref", + "ref": "app.bsky.actor.defs#profileViewDetailed" + } + } + } + } +} diff --git a/lexicons/com/atproto/identity/resolveHandle.json b/lexicons/com/atproto/identity/resolveHandle.json new file mode 100644 index 00000000..69751a52 --- /dev/null +++ b/lexicons/com/atproto/identity/resolveHandle.json @@ -0,0 +1,37 @@ +{ + "lexicon": 1, + "id": "com.atproto.identity.resolveHandle", + "defs": { + "main": { + "type": "query", + "description": "Resolves an atproto handle (hostname) to a DID. Does not necessarily bi-directionally verify against the the DID document.", + "parameters": { + "type": "params", + "required": ["handle"], + "properties": { + "handle": { + "type": "string", + "format": "handle", + "description": "The handle to resolve." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["did"], + "properties": { + "did": { "type": "string", "format": "did" } + } + } + }, + "errors": [ + { + "name": "HandleNotFound", + "description": "The resolution process confirmed that the handle does not resolve to any DID." + } + ] + } + } +} diff --git a/package.json b/package.json index ee606c01..d70fcab0 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "check:workspaces": "cd js/app && yarn run check", "fix": "git ls-files | xargs prettier --write --ignore-unknown", "postinstall": "husky && make js-lexicons", - "build": "yarn workspaces foreach --parallel --all run build", + "build": "yarn workspaces foreach -t --parallel --all run build", "prepare": "husky", "release": "lerna publish --force-publish", "precommit": "make precommit", diff --git a/pkg/api/api.go b/pkg/api/api.go index 7a644480..e472ecbe 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -12,7 +12,6 @@ import ( "net/http/httputil" "net/url" "os" - "slices" "strings" "sync" "time" @@ -37,6 +36,7 @@ import ( "stream.place/streamplace/pkg/mist/mistconfig" "stream.place/streamplace/pkg/model" "stream.place/streamplace/pkg/notifications" + "stream.place/streamplace/pkg/oproxy" "stream.place/streamplace/pkg/spmetrics" "stream.place/streamplace/pkg/spxrpc" "stream.place/streamplace/pkg/streamplace" @@ -121,15 +121,32 @@ func (fs AppHostingFS) Open(name string) (http.File, error) { // api/playback/iame.li/hls/source/000000000000.ts func (a *StreamplaceAPI) Handler(ctx context.Context) (http.Handler, error) { + var xrpc http.Handler xrpc, err := spxrpc.NewServer(a.CLI, a.Model) if err != nil { return nil, err } + op := oproxy.New(&oproxy.Config{ + Host: a.CLI.PublicHost, + CreateOAuthSession: a.Model.CreateOAuthSession, + UpdateOAuthSession: a.Model.UpdateOAuthSession, + LoadOAuthSession: a.Model.LoadOAuthSession, + Scope: "atproto transition:generic", + UpstreamJWK: a.CLI.JWK, + DownstreamJWK: a.CLI.AccessJWK, + }) + + xrpc = op.OAuthMiddleware(xrpc) router := httprouter.New() + router.Handler("GET", "/oauth/*anything", op.Handler()) + router.Handler("POST", "/oauth/*anything", op.Handler()) + router.Handler("GET", "/.well-known/oauth-authorization-server", op.Handler()) + router.Handler("GET", "/.well-known/oauth-protected-resource", op.Handler()) apiRouter := httprouter.New() apiRouter.HandlerFunc("POST", "/api/notification", a.HandleNotification(ctx)) // old clients router.HandlerFunc("GET", "/app-updates", a.HandleAppUpdates(ctx)) + // new ones apiRouter.HandlerFunc("GET", "/api/manifest", a.HandleAppUpdates(ctx)) apiRouter.GET("/api/desktop-updates/:platform/:architecture/:version/:buildTime/:file", a.HandleDesktopUpdates(ctx)) @@ -156,9 +173,6 @@ func (a *StreamplaceAPI) Handler(ctx context.Context) (http.Handler, error) { apiRouter.GET("/api/segment/recent", a.HandleRecentSegments(ctx)) apiRouter.GET("/api/segment/recent/:repoDID", a.HandleUserRecentSegments(ctx)) apiRouter.GET("/api/bluesky/resolve/:handle", a.HandleBlueskyResolve(ctx)) - for _, platform := range atproto.AllowedPlatforms { - apiRouter.GET(fmt.Sprintf("/api/atproto-oauth/%s", platform), a.HandleATProtoOAuth(ctx, platform)) - } apiRouter.GET("/api/live-users", a.HandleLiveUsers(ctx)) apiRouter.GET("/api/view-count/:user", a.HandleViewCount(ctx)) apiRouter.NotFound = a.HandleAPI404(ctx) @@ -591,28 +605,6 @@ func (a *StreamplaceAPI) HandleBlueskyResolve(ctx context.Context) httprouter.Ha } } -func (a *StreamplaceAPI) HandleATProtoOAuth(ctx context.Context, platform string) httprouter.Handle { - return func(w http.ResponseWriter, req *http.Request, params httprouter.Params) { - host, _, err := net.SplitHostPort(req.Host) - if err != nil { - host = req.Host - } - if !slices.Contains(atproto.AllowedPlatforms, platform) { - apierrors.WriteHTTPBadRequest(w, "unsupported platform", nil) - return - } - - meta := atproto.GetMetadata(host, platform, a.CLI.AppBundleID) - bs, err := json.Marshal(meta) - if err != nil { - apierrors.WriteHTTPInternalServerError(w, "could not marshal metadata", err) - return - } - w.Header().Set("Content-Type", "application/json") - w.Write(bs) - } -} - type ChatResponse struct { Post *bsky.FeedPost `json:"post"` Repo *model.Repo `json:"repo"` @@ -778,7 +770,7 @@ func (a *StreamplaceAPI) getLimiter(ip string) *rate.Limiter { limiter, exists := a.limiters[ip] if !exists { // 5 actions per second with a burst of 3 - limiter = rate.NewLimiter(rate.Limit(10.0), 8) + limiter = rate.NewLimiter(rate.Limit(20.0), 16) a.limiters[ip] = limiter } diff --git a/pkg/api/api_internal.go b/pkg/api/api_internal.go index fd656ced..8d5e9194 100644 --- a/pkg/api/api_internal.go +++ b/pkg/api/api_internal.go @@ -423,6 +423,20 @@ func (a *StreamplaceAPI) InternalHandler(ctx context.Context) (http.Handler, err w.Write(bs) }) + router.GET("/oauth-sessions", func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + sessions, err := a.Model.ListOAuthSessions() + if err != nil { + errors.WriteHTTPInternalServerError(w, "unable to get oauth sessions", err) + return + } + bs, err := json.Marshal(sessions) + if err != nil { + errors.WriteHTTPInternalServerError(w, "unable to marshal oauth sessions", err) + return + } + w.Write(bs) + }) + router.POST("/notification-blast", func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { var payload notificationpkg.NotificationBlast if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { diff --git a/pkg/api/app-return.html b/pkg/api/app-return.html index 4425916b..3fdf1a27 100644 --- a/pkg/api/app-return.html +++ b/pkg/api/app-return.html @@ -37,7 +37,7 @@ // authorized twice? I don't know why. I spent two hours trying to figure out why // without luck. You're welcome to try more if you want! In the meantime, using // an annoying button makes it work every time. - // document.location.href = `${appBundleId}:/${window.location.search}`; + document.location.href = `${appBundleId}:/${window.location.search}`; document.querySelector("button").addEventListener("click", () => { document.location.href = `${appBundleId}:/${window.location.search}`; }); diff --git a/pkg/api/playback.go b/pkg/api/playback.go index d5fe07c0..02e78bc2 100644 --- a/pkg/api/playback.go +++ b/pkg/api/playback.go @@ -226,6 +226,11 @@ func (a *StreamplaceAPI) HandleWebRTCIngest(ctx context.Context) httprouter.Hand } addrBytes = decoded[:32] didBytes = decoded[32:] + priv, err = atcrypto.ParsePrivateBytesK256(addrBytes) + if err != nil { + errors.WriteHTTPUnauthorized(w, "invalid authorization key (not valid atcrypto)", err) + return + } } key, _ := secp256k1.PrivKeyFromBytes(addrBytes) @@ -234,6 +239,11 @@ func (a *StreamplaceAPI) HandleWebRTCIngest(ctx context.Context) httprouter.Hand return } var signer crypto.Signer = key.ToECDSA() + pub, err := priv.PublicKey() + if err != nil { + apierrors.WriteHTTPUnauthorized(w, "invalid authorization key (could not parse as atcrypto)", err) + return + } did := string(didBytes) @@ -248,6 +258,15 @@ func (a *StreamplaceAPI) HandleWebRTCIngest(ctx context.Context) httprouter.Hand apierrors.WriteHTTPUnauthorized(w, "user is not allowed to stream", err) return } + signingKey, err := a.Model.GetSigningKey(ctx, pub.DIDKey(), repo.DID) + if err != nil { + apierrors.WriteHTTPUnauthorized(w, "signing key not found", err) + return + } + if signingKey == nil { + apierrors.WriteHTTPUnauthorized(w, "signing key not found", nil) + return + } } else { atkey, err := atproto.ParsePubKey(signer.Public()) if err != nil { diff --git a/pkg/atproto/atproto.go b/pkg/atproto/atproto.go index f76537f7..dc97cd4b 100644 --- a/pkg/atproto/atproto.go +++ b/pkg/atproto/atproto.go @@ -13,11 +13,8 @@ import ( "github.com/bluesky-social/indigo/atproto/identity" "github.com/bluesky-social/indigo/atproto/syntax" "github.com/bluesky-social/indigo/repo" - "github.com/bluesky-social/indigo/util" "github.com/bluesky-social/indigo/xrpc" "github.com/ipfs/go-cid" - "github.com/ipfs/go-datastore" - blockstore "github.com/ipfs/go-ipfs-blockstore" "go.opentelemetry.io/otel" "stream.place/streamplace/pkg/aqhttp" "stream.place/streamplace/pkg/constants" @@ -69,7 +66,7 @@ type mstNode struct { } func (atsync *ATProtoSynchronizer) SyncBlueskyRepo(ctx context.Context, handle string, mod model.Model) (*model.Repo, error) { - ctx = log.WithLogValues(ctx, "func", "SyncBlueskyRepo") + ctx = log.WithLogValues(ctx, "func", "SyncBlueskyRepo", "handle", handle) // Get handle-specific lock and ensure synchronized access ident, err := ResolveIdent(ctx, handle) @@ -96,7 +93,6 @@ func (atsync *ATProtoSynchronizer) SyncBlueskyRepo(ctx context.Context, handle s DID: ident.DID.String(), PDS: ident.PDSEndpoint(), Version: "", - RootCID: "", Handle: ident.Handle.String(), } err = mod.UpdateRepo(&newRepo) @@ -130,47 +126,10 @@ func (atsync *ATProtoSynchronizer) SyncBlueskyRepo(ctx context.Context, handle s log.Log(ctx, "got diff", "bytes", len(repoBytes)) - bs := blockstore.NewBlockstore(datastore.NewMapDatastore()) - root, err := repo.IngestRepo(ctx, bs, bytes.NewReader(repoBytes)) - if err != nil { - return nil, fmt.Errorf("failed to ingest repo for %s: %w", ident.DID.String(), err) - } - log.Log(ctx, "ingested repo", "root", root) - if oldRepo != nil && oldRepo.RootCID != "" { - oldRoot, err := cid.Decode(oldRepo.RootCID) - if err != nil { - return nil, fmt.Errorf("failed to decode old root CID for %s: %w", ident.DID.String(), err) - } - if oldRoot.Equals(root) { - log.Debug(ctx, "no changes to repo", "root", root) - return oldRepo, nil - } - } - r, err := repo.ReadRepoFromCar(ctx, bytes.NewReader(repoBytes)) if err != nil { return nil, fmt.Errorf("failed to parse repo CAR data for %s: %w", ident.DID.String(), err) } - - mstNodes := map[string]mstNode{} - err = r.ForEach(ctx, "", func(k string, v cid.Cid) error { - nsid, rkey, err := syntax.ParseRepoPath(k) - if err != nil { - log.Warn(ctx, "failed to parse repo path", "k", k, "err", err) - return err - } - hash := v.Hash().HexString() - log.Debug(ctx, "got mst node", "cid", v, "rkey", rkey, "nsid", nsid, "hash", hash) - mstNodes[hash] = mstNode{ - rkey: rkey, - collection: nsid, - } - return nil - }) - if err != nil { - return nil, fmt.Errorf("failed to iterate over repo: %w", err) - } - // extract DID from repo commit sc := r.SignedCommit() signerDID, err := syntax.ParseDID(sc.Did) @@ -181,46 +140,34 @@ func (atsync *ATProtoSynchronizer) SyncBlueskyRepo(ctx context.Context, handle s return nil, fmt.Errorf("signer DID %s does not match identity %s", signerDID, ident.DID.String()) } - bs = r.Blockstore() - cst := util.CborStore(bs) - allKeys, err := bs.AllKeysChan(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get all keys: %w", err) - } - for k := range allKeys { - blk, err := bs.Get(ctx, k) + err = r.ForEach(ctx, "", func(k string, v cid.Cid) error { + nsid, rkey, err := syntax.ParseRepoPath(k) if err != nil { - return nil, fmt.Errorf("failed to get block for key %s: %w", k, err) + log.Warn(ctx, "failed to parse repo path", "k", k, "err", err) + return fmt.Errorf("could not parse repo path %s: %w", k, err) } - rec := map[string]any{} - err = cst.Get(ctx, k, &rec) + _, bs, err := r.GetRecordBytes(ctx, k) if err != nil { - return nil, fmt.Errorf("failed to get block for key %s: %w", k, err) - } - typ, ok := rec["$type"] - if !ok { - log.Debug(ctx, "record type not found", "key", k) - continue + log.Warn(ctx, "failed to get record bytes", "k", k, "rkey", rkey, "err", err) + return fmt.Errorf("could not retrieve record bytes for %s (rkey: %s): %w", k, rkey, err) } - log.Debug(ctx, "record type", "key", k, "type", typ) - hash := k.Hash().HexString() - node, ok := mstNodes[hash] - if !ok { - log.Warn(ctx, "no mst node found for record", "key", k, "hash", hash) - continue - } - rawData := blk.RawData() - err = atsync.handleCreateUpdate(ctx, signerDID.String(), node.rkey, &rawData, k.String(), node.collection) + log.Debug(ctx, "record type", "key", k, "type", nsid.String()) + err = atsync.handleCreateUpdate(ctx, signerDID.String(), rkey, bs, v.String(), nsid) if err != nil { log.Warn(ctx, "failed to handle create update", "err", err) + // invalid CBOR and stuff should get ignored, so + // return fmt.Errorf("failed to process record update for %s (type: %s): %w", k, nsid.String(), err) } + return nil + }) + if err != nil { + return nil, fmt.Errorf("failed to iterate over repo: %w", err) } newRepo := model.Repo{ DID: ident.DID.String(), PDS: ident.PDSEndpoint(), Version: sc.Rev, - RootCID: root.String(), Handle: ident.Handle.String(), } err = mod.UpdateRepo(&newRepo) diff --git a/pkg/atproto/client_metadata.go b/pkg/atproto/client_metadata.go deleted file mode 100644 index bedc4fa7..00000000 --- a/pkg/atproto/client_metadata.go +++ /dev/null @@ -1,78 +0,0 @@ -package atproto - -import ( - "fmt" -) - -var AllowedPlatforms = []string{"ios", "android", "web"} - -type OAuthClientMetadata struct { - RedirectURIs []string `json:"redirect_uris"` - ResponseTypes []string `json:"response_types,omitempty"` - GrantTypes []string `json:"grant_types,omitempty"` - Scope string `json:"scope,omitempty"` - TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"` - TokenEndpointAuthSigningAlg string `json:"token_endpoint_auth_signing_alg,omitempty"` - UserinfoSignedResponseAlg string `json:"userinfo_signed_response_alg,omitempty"` - UserinfoEncryptedResponseAlg string `json:"userinfo_encrypted_response_alg,omitempty"` - JwksURI string `json:"jwks_uri,omitempty"` - ApplicationType string `json:"application_type,omitempty"` // "web" or "native" - SubjectType string `json:"subject_type,omitempty"` // "public" or "pairwise" - RequestObjectSigningAlg string `json:"request_object_signing_alg,omitempty"` - IDTokenSignedResponseAlg string `json:"id_token_signed_response_alg,omitempty"` - AuthorizationSignedResponseAlg string `json:"authorization_signed_response_alg,omitempty"` - AuthorizationEncryptedResponseEnc string `json:"authorization_encrypted_response_enc,omitempty"` - AuthorizationEncryptedResponseAlg string `json:"authorization_encrypted_response_alg,omitempty"` - ClientID string `json:"client_id,omitempty"` - ClientName string `json:"client_name,omitempty"` - ClientURI string `json:"client_uri,omitempty"` - PolicyURI string `json:"policy_uri,omitempty"` - TosURI string `json:"tos_uri,omitempty"` - LogoURI string `json:"logo_uri,omitempty"` - DefaultMaxAge int `json:"default_max_age,omitempty"` - RequireAuthTime *bool `json:"require_auth_time,omitempty"` - Contacts []string `json:"contacts,omitempty"` - TLSClientCertificateBoundAccessTokens *bool `json:"tls_client_certificate_bound_access_tokens,omitempty"` - DPoPBoundAccessTokens *bool `json:"dpop_bound_access_tokens,omitempty"` - AuthorizationDetailsTypes []string `json:"authorization_details_types,omitempty"` - // Jwks *JWKSet `json:"jwks,omitempty"` // You'll need to define JWKSet type -} - -func boolPtr(b bool) *bool { - return &b -} - -func GetMetadata(host string, platform string, appBundleId string) *OAuthClientMetadata { - meta := &OAuthClientMetadata{ - ClientID: fmt.Sprintf("https://%s/api/atproto-oauth/%s", host, platform), - ClientURI: fmt.Sprintf("https://%s", host), - // RedirectURIs: []string{fmt.Sprintf("https://%s/login", host)}, - Scope: "atproto transition:generic", - TokenEndpointAuthMethod: "none", - ClientName: "Streamplace", - ResponseTypes: []string{"code"}, - GrantTypes: []string{"authorization_code", "refresh_token"}, - DPoPBoundAccessTokens: boolPtr(true), - } - if platform == "web" { - meta.RedirectURIs = []string{fmt.Sprintf("https://%s/login", host)} - meta.ApplicationType = "web" - } else { - meta.RedirectURIs = []string{fmt.Sprintf("https://%s/api/app-return/%s", host, appBundleId)} - meta.ApplicationType = "native" - } - return meta -} - -// clientMetadata: { -// client_id: "http://localhost?scope=atproto%20transition:generic", -// redirect_uris: ["http://127.0.0.1:38081"], -// scope: "atproto transition:generic", -// token_endpoint_auth_method: "none", -// // jwks_uri: "https://my-app.example/jwks.json", -// client_name: "Loopback client", -// response_types: ["code"], -// grant_types: ["authorization_code", "refresh_token"], -// application_type: "native", -// dpop_bound_access_tokens: true, -// }, diff --git a/pkg/atproto/jwks.go b/pkg/atproto/jwks.go new file mode 100644 index 00000000..e5c5e02d --- /dev/null +++ b/pkg/atproto/jwks.go @@ -0,0 +1,45 @@ +package atproto + +import ( + "context" + "encoding/json" + "os" + + oauth_helpers "github.com/haileyok/atproto-oauth-golang/helpers" + "github.com/lestrrat-go/jwx/v2/jwk" + "stream.place/streamplace/pkg/log" +) + +func EnsureJWK(ctx context.Context, fPath string) (jwk.Key, error) { + var key jwk.Key + _, err := os.Stat(fPath) + if err == nil { + b, err := os.ReadFile(fPath) + if err != nil { + return nil, err + } + key, err = jwk.ParseKey(b) + if err != nil { + return nil, err + } + } else if os.IsNotExist(err) { + key, err = oauth_helpers.GenerateKey(nil) + if err != nil { + return nil, err + } + + b, err := json.Marshal(key) + if err != nil { + return nil, err + } + + if err := os.WriteFile(fPath, b, 0600); err != nil { + return nil, err + } + log.Log(ctx, "generated JWK", "path", fPath) + } else { + return nil, err + } + + return key, nil +} diff --git a/pkg/atproto/sync.go b/pkg/atproto/sync.go index 7fee72d2..a5cfa262 100644 --- a/pkg/atproto/sync.go +++ b/pkg/atproto/sync.go @@ -2,6 +2,7 @@ package atproto import ( "context" + "errors" "fmt" "reflect" "time" @@ -19,7 +20,7 @@ import ( ) func (atsync *ATProtoSynchronizer) handleCreateUpdate(ctx context.Context, userDID string, rkey syntax.RecordKey, recCBOR *[]byte, cid string, collection syntax.NSID) error { - ctx = log.WithLogValues(ctx, "func", "handleCreateUpdate") + ctx = log.WithLogValues(ctx, "func", "handleCreateUpdate", "userDID", userDID, "rkey", rkey.String(), "cid", cid, "collection", collection.String()) now := time.Now() r, err := atsync.Model.GetRepo(userDID) if err != nil { @@ -32,11 +33,14 @@ func (atsync *ATProtoSynchronizer) handleCreateUpdate(ctx context.Context, userD } d, err := data.UnmarshalCBOR(*recCBOR) if err != nil { - return fmt.Errorf("failed to parse record CBOR: %w", err) + return fmt.Errorf("failed to unmarhsal record CBOR: %w", err) } cb, err := lexutil.CborDecodeValue(*recCBOR) - if err != nil { - return fmt.Errorf("failed to parse record CBOR: %w", err) + if errors.Is(err, lexutil.ErrUnrecognizedType) { + log.Debug(ctx, "unrecognized record type", "key", rkey.String(), "type", err) + return nil + } else if err != nil { + return fmt.Errorf("failed to decode record CBOR: %w", err) } switch rec := cb.(type) { case *bsky.GraphFollow: diff --git a/pkg/cmd/streamplace.go b/pkg/cmd/streamplace.go index aa1ac1e3..16d8cfb2 100644 --- a/pkg/cmd/streamplace.go +++ b/pkg/cmd/streamplace.go @@ -145,6 +145,7 @@ func start(build *config.BuildFlags, platformJobs []jobFunc) error { fs.StringVar(&cli.RelayHost, "relay-host", "wss://bsky.network", "websocket url for relay firehose") fs.Bool("insecure", false, "DEPRECATED, does nothing.") fs.StringVar(&cli.Color, "color", "", "'true' to enable colorized logging, 'false' to disable") + fs.StringVar(&cli.PublicHost, "public-host", "", "public host for this streamplace node (excluding https:// e.g. stream.place)") fs.BoolVar(&cli.Thumbnail, "thumbnail", true, "enable thumbnail generation") fs.BoolVar(&cli.SmearAudio, "smear-audio", false, "enable audio smearing to create 'perfect' segment timestamps") fs.BoolVar(&cli.ExternalSigning, "external-signing", false, "enable external signing via exec (prevents potential memory leak)") @@ -288,6 +289,21 @@ func start(build *config.BuildFlags, platformJobs []jobFunc) error { return err } } + + jwkPath := cli.DataFilePath([]string{"jwk.json"}) + jwk, err := atproto.EnsureJWK(ctx, jwkPath) + if err != nil { + return err + } + cli.JWK = jwk + + accessJWKPath := cli.DataFilePath([]string{"access-jwk.json"}) + accessJWK, err := atproto.EnsureJWK(ctx, accessJWKPath) + if err != nil { + return err + } + cli.AccessJWK = accessJWK + b := bus.NewBus() atsync := &atproto.ATProtoSynchronizer{ CLI: &cli, diff --git a/pkg/config/config.go b/pkg/config/config.go index 03e7931f..315c9c46 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -91,9 +91,10 @@ type CLI struct { SmearAudio bool ExternalSigning bool TracingEndpoint string + PublicHost string JWK jwk.Key - - dataDirFlags []*string + AccessJWK jwk.Key + dataDirFlags []*string } var STREAMPLACE_SCHEME_PREFIX = "streamplace://" diff --git a/pkg/model/model.go b/pkg/model/model.go index d2d2961a..04e0b303 100644 --- a/pkg/model/model.go +++ b/pkg/model/model.go @@ -13,12 +13,15 @@ import ( slogGorm "github.com/orandin/slog-gorm" "gorm.io/driver/sqlite" "gorm.io/gorm" + "stream.place/streamplace/pkg/config" "stream.place/streamplace/pkg/log" + "stream.place/streamplace/pkg/oproxy" "stream.place/streamplace/pkg/streamplace" ) type DBModel struct { - DB *gorm.DB + DB *gorm.DB + CLI *config.CLI } type Model interface { @@ -79,6 +82,11 @@ type Model interface { CreateChatProfile(ctx context.Context, profile *ChatProfile) error GetChatProfile(ctx context.Context, repoDID string) (*ChatProfile, error) + + CreateOAuthSession(id string, session *oproxy.OAuthSession) error + LoadOAuthSession(id string) (*oproxy.OAuthSession, error) + UpdateOAuthSession(id string, session *oproxy.OAuthSession) error + ListOAuthSessions() ([]oproxy.OAuthSession, error) } func MakeDB(dbURL string) (Model, error) { @@ -135,6 +143,7 @@ func MakeDB(dbURL string) (Model, error) { Block{}, ChatMessage{}, ChatProfile{}, + oproxy.OAuthSession{}, } { err = db.AutoMigrate(model) if err != nil { diff --git a/pkg/model/oauth_session.go b/pkg/model/oauth_session.go new file mode 100644 index 00000000..e482809a --- /dev/null +++ b/pkg/model/oauth_session.go @@ -0,0 +1,42 @@ +package model + +import ( + "errors" + + "gorm.io/gorm" + "stream.place/streamplace/pkg/oproxy" +) + +func (m *DBModel) CreateOAuthSession(id string, session *oproxy.OAuthSession) error { + return m.DB.Create(session).Error +} + +func (m *DBModel) LoadOAuthSession(id string) (*oproxy.OAuthSession, error) { + var session oproxy.OAuthSession + if err := m.DB.Where("downstream_dpop_jkt = ?", id).First(&session).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return &session, nil +} + +func (m *DBModel) UpdateOAuthSession(id string, session *oproxy.OAuthSession) error { + res := m.DB.Model(&oproxy.OAuthSession{}).Where("downstream_dpop_jkt = ?", id).Updates(session) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return errors.New("no rows affected") + } + return nil +} + +func (m *DBModel) ListOAuthSessions() ([]oproxy.OAuthSession, error) { + var sessions []oproxy.OAuthSession + if err := m.DB.Find(&sessions).Error; err != nil { + return nil, err + } + return sessions, nil +} diff --git a/pkg/oproxy/dpop_helpers.go b/pkg/oproxy/dpop_helpers.go new file mode 100644 index 00000000..f86791b0 --- /dev/null +++ b/pkg/oproxy/dpop_helpers.go @@ -0,0 +1,197 @@ +package oproxy + +import ( + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "math/big" + "strings" + + "github.com/AxisCommunications/go-dpop" + "github.com/golang-jwt/jwt/v5" +) + +// all of this code borrowed from https://github.com/AxisCommunications/go-dpop +// MIT license +func keyFunc(t *jwt.Token) (interface{}, error) { + // Return the required jwkHeader header. See https://datatracker.ietf.org/doc/html/rfc9449#section-4.2 + // Used to validate the signature of the DPoP proof. + jwkHeader := t.Header["jwk"] + if jwkHeader == nil { + return nil, dpop.ErrMissingJWK + } + + jwkMap, ok := jwkHeader.(map[string]interface{}) + if !ok { + return nil, dpop.ErrMissingJWK + } + + return parseJwk(jwkMap) +} + +// Parses a JWK and inherently strips it of optional fields +func parseJwk(jwkMap map[string]interface{}) (interface{}, error) { + // Ensure that JWK kty is present and is a string. + kty, ok := jwkMap["kty"].(string) + if !ok { + return nil, dpop.ErrInvalidProof + } + switch kty { + case "EC": + // Ensure that the required fields are present and are strings. + x, ok := jwkMap["x"].(string) + if !ok { + return nil, dpop.ErrInvalidProof + } + y, ok := jwkMap["y"].(string) + if !ok { + return nil, dpop.ErrInvalidProof + } + crv, ok := jwkMap["crv"].(string) + if !ok { + return nil, dpop.ErrInvalidProof + } + + // Decode the coordinates from Base64. + // + // According to RFC 7518, they are Base64 URL unsigned integers. + // https://tools.ietf.org/html/rfc7518#section-6.3 + xCoordinate, err := base64urlTrailingPadding(x) + if err != nil { + return nil, err + } + yCoordinate, err := base64urlTrailingPadding(y) + if err != nil { + return nil, err + } + + // Read the specified curve of the key. + var curve elliptic.Curve + switch crv { + case "P-256": + curve = elliptic.P256() + case "P-384": + curve = elliptic.P384() + case "P-521": + curve = elliptic.P521() + default: + return nil, dpop.ErrUnsupportedCurve + } + + return &ecdsa.PublicKey{ + X: big.NewInt(0).SetBytes(xCoordinate), + Y: big.NewInt(0).SetBytes(yCoordinate), + Curve: curve, + }, nil + case "RSA": + // Ensure that the required fields are present and are strings. + e, ok := jwkMap["e"].(string) + if !ok { + return nil, dpop.ErrInvalidProof + } + n, ok := jwkMap["n"].(string) + if !ok { + return nil, dpop.ErrInvalidProof + } + + // Decode the exponent and modulus from Base64. + // + // According to RFC 7518, they are Base64 URL unsigned integers. + // https://tools.ietf.org/html/rfc7518#section-6.3 + exponent, err := base64urlTrailingPadding(e) + if err != nil { + return nil, err + } + modulus, err := base64urlTrailingPadding(n) + if err != nil { + return nil, err + } + return &rsa.PublicKey{ + N: big.NewInt(0).SetBytes(modulus), + E: int(big.NewInt(0).SetBytes(exponent).Uint64()), + }, nil + case "OKP": + // Ensure that the required fields are present and are strings. + x, ok := jwkMap["x"].(string) + if !ok { + return nil, dpop.ErrInvalidProof + } + + publicKey, err := base64urlTrailingPadding(x) + if err != nil { + return nil, err + } + + return ed25519.PublicKey(publicKey), nil + case "OCT": + return nil, dpop.ErrUnsupportedKeyAlgorithm + default: + return nil, dpop.ErrUnsupportedKeyAlgorithm + } +} + +// Borrowed from MicahParks/keyfunc See: https://github.com/MicahParks/keyfunc/blob/master/keyfunc.go#L56 +// +// base64urlTrailingPadding removes trailing padding before decoding a string from base64url. Some non-RFC compliant +// JWKS contain padding at the end values for base64url encoded public keys. +// +// Trailing padding is required to be removed from base64url encoded keys. +// RFC 7517 Section 1.1 defines base64url the same as RFC 7515 Section 2: +// https://datatracker.ietf.org/doc/html/rfc7517#section-1.1 +// https://datatracker.ietf.org/doc/html/rfc7515#section-2 +func base64urlTrailingPadding(s string) ([]byte, error) { + s = strings.TrimRight(s, "=") + return base64.RawURLEncoding.DecodeString(s) +} + +// Strips eventual optional members of a JWK in order to be able to compute the thumbprint of it +// https://datatracker.ietf.org/doc/html/rfc7638#section-3.2 +func getThumbprintableJwkJSONbytes(jwk map[string]interface{}) ([]byte, error) { + minimalJwk, err := parseJwk(jwk) + if err != nil { + return nil, err + } + jwkHeaderJSONBytes, err := getKeyStringRepresentation(minimalJwk) + if err != nil { + return nil, err + } + return jwkHeaderJSONBytes, nil +} + +// Returns the string representation of a key in JSON format. +func getKeyStringRepresentation(key interface{}) ([]byte, error) { + var keyParts interface{} + switch key := key.(type) { + case *ecdsa.PublicKey: + // Calculate the size of the byte array representation of an elliptic curve coordinate + // and ensure that the byte array representation of the key is padded correctly. + bits := key.Curve.Params().BitSize + keyCurveBytesSize := bits/8 + bits%8 + + keyParts = map[string]interface{}{ + "kty": "EC", + "crv": key.Curve.Params().Name, + "x": base64.RawURLEncoding.EncodeToString(key.X.FillBytes(make([]byte, keyCurveBytesSize))), + "y": base64.RawURLEncoding.EncodeToString(key.Y.FillBytes(make([]byte, keyCurveBytesSize))), + } + case *rsa.PublicKey: + keyParts = map[string]interface{}{ + "kty": "RSA", + "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.E)).Bytes()), + "n": base64.RawURLEncoding.EncodeToString(key.N.Bytes()), + } + case ed25519.PublicKey: + keyParts = map[string]interface{}{ + "kty": "OKP", + "crv": "Ed25519", + "x": base64.RawURLEncoding.EncodeToString(key), + } + default: + return nil, dpop.ErrUnsupportedKeyAlgorithm + } + + return json.Marshal(keyParts) +} diff --git a/pkg/oproxy/helpers.go b/pkg/oproxy/helpers.go new file mode 100644 index 00000000..1d28ff17 --- /dev/null +++ b/pkg/oproxy/helpers.go @@ -0,0 +1,99 @@ +package oproxy + +import ( + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "strings" + + "github.com/AxisCommunications/go-dpop" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +func boolPtr(b bool) *bool { + return &b +} + +func codeUUID(prefix string) string { + uu, err := uuid.NewV7() + if err != nil { + panic(err) + } + return fmt.Sprintf("%s-%s", prefix, uu.String()) +} + +var urnPrefix = "urn:ietf:params:oauth:request_uri:" + +const UUID_LENGTH = 37 + +func makeURN(jkt string) string { + uu, err := uuid.NewV7() + if err != nil { + panic(err) + } + return fmt.Sprintf("%s%s-%s", urnPrefix, uu.String(), jkt) +} + +// urn --> jkt, uu +func parseURN(urn string) (string, string, error) { + if !strings.HasPrefix(urn, urnPrefix) { + return "", "", fmt.Errorf("invalid URN: %s", urn) + } + withoutPrefix := urn[len(urnPrefix):] + uu := withoutPrefix[:UUID_LENGTH] + suffix := withoutPrefix[UUID_LENGTH:] + return suffix, uu, nil +} + +func makeState(jkt string) string { + uu, err := uuid.NewV7() + if err != nil { + panic(err) + } + return fmt.Sprintf("%s-%s", uu.String(), jkt) +} + +func parseState(state string) (string, string, error) { + if len(state) < UUID_LENGTH { + return "", "", fmt.Errorf("invalid state: %s", state) + } + uu := state[:UUID_LENGTH] + suffix := state[UUID_LENGTH:] + return suffix, uu, nil +} + +func makeNonce() string { + uu, err := uuid.NewV7() + if err != nil { + panic(err) + } + return fmt.Sprintf("nonce-%s", uu.String()) +} + +// returns jkt, nonce, error +func getJKT(dpopJWT string) (string, string, error) { + var claims dpop.ProofTokenClaims + token, err := jwt.ParseWithClaims(dpopJWT, &claims, keyFunc) + if err != nil { + return "", "", err + } + jwk, ok := token.Header["jwk"].(map[string]any) + if !ok { + return "", "", fmt.Errorf("missing jwk in DPoP JWT header") + } + jwkJSONbytes, err := getThumbprintableJwkJSONbytes(jwk) + if err != nil { + // keyFunc used with parseWithClaims should ensure that this can not happen but better safe than sorry. + return "", "", errors.Join(dpop.ErrInvalidProof, err) + } + h := sha256.New() + _, err = h.Write(jwkJSONbytes) + if err != nil { + return "", "", errors.Join(dpop.ErrInvalidProof, err) + } + b64URLjwkHash := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) + + return b64URLjwkHash, claims.Nonce, nil +} diff --git a/pkg/oproxy/oauth_0_metadata.go b/pkg/oproxy/oauth_0_metadata.go new file mode 100644 index 00000000..73a4f4ac --- /dev/null +++ b/pkg/oproxy/oauth_0_metadata.go @@ -0,0 +1,155 @@ +package oproxy + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/haileyok/atproto-oauth-golang/helpers" + "github.com/labstack/echo/v4" +) + +func (o *OProxy) HandleOAuthAuthorizationServer(c echo.Context) error { + c.Response().Header().Set("Access-Control-Allow-Origin", "*") + c.Response().Header().Set("Content-Type", "application/json") + c.Response().WriteHeader(200) + json.NewEncoder(c.Response().Writer).Encode(generateOAuthServerMetadata(o.host)) + return nil +} + +func (o *OProxy) HandleOAuthProtectedResource(c echo.Context) error { + return c.JSON(200, map[string]interface{}{ + "resource": fmt.Sprintf("https://%s", o.host), + "authorization_servers": []string{ + fmt.Sprintf("https://%s", o.host), + }, + "scopes_supported": []string{}, + "bearer_methods_supported": []string{ + "header", + }, + "resource_documentation": "https://atproto.com", + }) +} + +func (o *OProxy) HandleClientMetadataUpstream(c echo.Context) error { + meta := o.GetUpstreamMetadata() + return c.JSON(200, meta) +} + +func (o *OProxy) HandleJwksUpstream(c echo.Context) error { + pubKey, err := o.upstreamJWK.PublicKey() + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "could not get public key") + } + return c.JSON(200, helpers.CreateJwksResponseObject(pubKey)) +} + +func (o *OProxy) HandleClientMetadataDownstream(c echo.Context) error { + redirectURI := c.QueryParam("redirect_uri") + meta, err := o.GetDownstreamMetadata(redirectURI) + if err != nil { + return err + } + return c.JSON(200, meta) +} + +func (o *OProxy) GetUpstreamMetadata() *OAuthClientMetadata { + // publicKey, err := o.upstreamJWK.PublicKey() + // if err != nil { + // panic(err) + // } + // jwks := jwk.NewSet() + // err = jwks.AddKey(publicKey) + // if err != nil { + // panic(err) + // } + // ro := helpers.CreateJwksResponseObject(publicKey) + meta := &OAuthClientMetadata{ + ClientID: fmt.Sprintf("https://%s/oauth/upstream/client-metadata.json", o.host), + JwksURI: fmt.Sprintf("https://%s/oauth/upstream/jwks.json", o.host), + ClientURI: fmt.Sprintf("https://%s", o.host), + // RedirectURIs: []string{fmt.Sprintf("https://%s/login", host)}, + Scope: "atproto transition:generic", + TokenEndpointAuthMethod: "private_key_jwt", + ClientName: "Streamplace", + ResponseTypes: []string{"code"}, + GrantTypes: []string{"authorization_code", "refresh_token"}, + DPoPBoundAccessTokens: boolPtr(true), + TokenEndpointAuthSigningAlg: "ES256", + RedirectURIs: []string{fmt.Sprintf("https://%s/oauth/return", o.host)}, + // Jwks: ro, + } + return meta +} + +func generateOAuthServerMetadata(host string) map[string]any { + oauthServerMetadata := map[string]any{ + "issuer": fmt.Sprintf("https://%s", host), + "request_parameter_supported": true, + "request_uri_parameter_supported": true, + "require_request_uri_registration": true, + "scopes_supported": []string{"atproto", "transition:generic", "transition:chat.bsky"}, + "subject_types_supported": []string{"public"}, + "response_types_supported": []string{"code"}, + "response_modes_supported": []string{"query", "fragment", "form_post"}, + "grant_types_supported": []string{"authorization_code", "refresh_token"}, + "code_challenge_methods_supported": []string{"S256"}, + "ui_locales_supported": []string{"en-US"}, + "display_values_supported": []string{"page", "popup", "touch"}, + "authorization_response_iss_parameter_supported": true, + "request_object_encryption_alg_values_supported": []string{}, + "request_object_encryption_enc_values_supported": []string{}, + "jwks_uri": fmt.Sprintf("https://%s/oauth/jwks", host), + "authorization_endpoint": fmt.Sprintf("https://%s/oauth/authorize", host), + "token_endpoint": fmt.Sprintf("https://%s/oauth/token", host), + "token_endpoint_auth_methods_supported": []string{"none", "private_key_jwt"}, + "revocation_endpoint": fmt.Sprintf("https://%s/oauth/revoke", host), + "introspection_endpoint": fmt.Sprintf("https://%s/oauth/introspect", host), + "pushed_authorization_request_endpoint": fmt.Sprintf("https://%s/oauth/par", host), + "require_pushed_authorization_requests": true, + "client_id_metadata_document_supported": true, + "request_object_signing_alg_values_supported": []string{ + "RS256", "RS384", "RS512", "PS256", "PS384", "PS512", + "ES256", "ES256K", "ES384", "ES512", "none", + }, + "token_endpoint_auth_signing_alg_values_supported": []string{ + "RS256", "RS384", "RS512", "PS256", "PS384", "PS512", + "ES256", "ES256K", "ES384", "ES512", + }, + "dpop_signing_alg_values_supported": []string{ + "RS256", "RS384", "RS512", "PS256", "PS384", "PS512", + "ES256", "ES256K", "ES384", "ES512", + }, + } + return oauthServerMetadata +} + +func (o *OProxy) GetDownstreamMetadata(redirectURI string) (*OAuthClientMetadata, error) { + meta := &OAuthClientMetadata{ + ClientID: fmt.Sprintf("https://%s/oauth/downstream/client-metadata.json", o.host), + ClientURI: fmt.Sprintf("https://%s", o.host), + // RedirectURIs: []string{fmt.Sprintf("https://%s/login", host)}, + Scope: "atproto transition:generic", + TokenEndpointAuthMethod: "none", + ClientName: "Streamplace", + ResponseTypes: []string{"code"}, + GrantTypes: []string{"authorization_code", "refresh_token"}, + DPoPBoundAccessTokens: boolPtr(true), + RedirectURIs: []string{fmt.Sprintf("https://%s/login", o.host), fmt.Sprintf("https://%s/api/app-return", o.host)}, + ApplicationType: "web", + } + if redirectURI != "" { + found := false + for _, uri := range meta.RedirectURIs { + if uri == redirectURI { + found = true + break + } + } + if !found { + return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid redirect_uri: %s not in allowed URIs", redirectURI)) + } + meta.RedirectURIs = []string{redirectURI} + } + return meta, nil +} diff --git a/pkg/oproxy/oauth_1_par.go b/pkg/oproxy/oauth_1_par.go new file mode 100644 index 00000000..34bfd435 --- /dev/null +++ b/pkg/oproxy/oauth_1_par.go @@ -0,0 +1,178 @@ +package oproxy + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "slices" + + "github.com/AxisCommunications/go-dpop" + "github.com/labstack/echo/v4" + "go.opentelemetry.io/otel" +) + +type PAR struct { + ClientID string `json:"client_id"` + RedirectURI string `json:"redirect_uri"` + CodeChallenge string `json:"code_challenge"` + CodeChallengeMethod string `json:"code_challenge_method"` + State string `json:"state"` + LoginHint string `json:"login_hint"` + ResponseMode string `json:"response_mode"` + ResponseType string `json:"response_type"` + Scope string `json:"scope"` +} + +type PARResponse struct { + RequestURI string `json:"request_uri"` + ExpiresIn int `json:"expires_in"` +} + +var ErrFirstNonce = echo.NewHTTPError(http.StatusBadRequest, "first time seeing this key, come back with a nonce") + +func (o *OProxy) HandleOAuthPAR(c echo.Context) error { + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandleOAuthPAR") + defer span.End() + c.Response().Header().Set("Access-Control-Allow-Origin", "*") + var par PAR + if err := json.NewDecoder(c.Request().Body).Decode(&par); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + + dpopHeader := c.Request().Header.Get("DPoP") + if dpopHeader == "" { + return echo.NewHTTPError(http.StatusUnauthorized, "DPoP header is required") + } + + resp, err := o.NewPAR(ctx, c, &par, dpopHeader) + if errors.Is(err, ErrFirstNonce) { + res := map[string]interface{}{ + "error": "use_dpop_nonce", + "error_description": "Authorization server requires nonce in DPoP proof", + } + return c.JSON(http.StatusBadRequest, res) + } else if err != nil { + return err + } + return c.JSON(http.StatusCreated, resp) +} + +func (o *OProxy) NewPAR(ctx context.Context, c echo.Context, par *PAR, dpopHeader string) (*PARResponse, error) { + jkt, nonce, err := getJKT(dpopHeader) + if err != nil { + return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to get JKT from DPoP header header=%s: %s", dpopHeader, err)) + } + session, err := o.loadOAuthSession(jkt) + if err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to load OAuth session: %s", err)) + } + // special case - if this is the first request, we need to send it back for a new nonce + if session == nil { + _, err := dpop.Parse(dpopHeader, dpop.POST, &url.URL{Host: o.host, Scheme: "https", Path: "/oauth/par"}, dpop.ParseOptions{ + Nonce: nonce, // normally this would be bad! but on the first request we're revalidating nonce anyway + TimeWindow: &dpopTimeWindow, + }) + if err != nil { + return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to parse DPoP header: %s", err)) + } + newNonce := makeNonce() + err = o.createOAuthSession(jkt, &OAuthSession{ + DownstreamDPoPJKT: jkt, + DownstreamDPoPNonce: newNonce, + }) + if err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create OAuth session: %s", err)) + } + // come back later, nerd + c.Response().Header().Set("DPoP-Nonce", newNonce) + return nil, ErrFirstNonce + } + if session.DownstreamDPoPNonce != nonce { + return nil, echo.NewHTTPError(http.StatusBadRequest, "invalid nonce") + } + proof, err := dpop.Parse(dpopHeader, dpop.POST, &url.URL{Host: o.host, Scheme: "https", Path: "/oauth/par"}, dpop.ParseOptions{ + Nonce: session.DownstreamDPoPNonce, + TimeWindow: &dpopTimeWindow, + }) + // Check the error type to determine response + if err != nil { + // if ok := errors.Is(err, dpop.ErrInvalidProof); ok { + // apierrors.WriteHTTPBadRequest(w, "invalid DPoP proof", nil) + // return + // } + // apierrors.WriteHTTPBadRequest(w, "invalid DPoP proof", err) + // return + return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid DPoP proof: %s", err)) + } + if proof.PublicKey() != jkt { + panic("invalid code path: parsed DPoP proof twice and got different keys?!") + } + + clientMetadata, err := o.GetDownstreamMetadata(par.RedirectURI) + if err != nil { + return nil, err + } + if par.ClientID != clientMetadata.ClientID { + return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid client_id: expected %s, got %s", clientMetadata.ClientID, par.ClientID)) + } + + if !slices.Contains(clientMetadata.RedirectURIs, par.RedirectURI) { + return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid redirect_uri: %s not in allowed URIs", par.RedirectURI)) + } + + if par.CodeChallengeMethod != "S256" { + return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid code challenge method: expected S256, got %s", par.CodeChallengeMethod)) + } + + if par.ResponseMode != "query" { + return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid response mode: expected query, got %s", par.ResponseMode)) + } + + if par.ResponseType != "code" { + return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid response type: expected code, got %s", par.ResponseType)) + } + + if par.Scope != o.scope { + return nil, echo.NewHTTPError(http.StatusBadRequest, "invalid scope") + } + + if par.LoginHint == "" { + return nil, echo.NewHTTPError(http.StatusBadRequest, "login hint is required to find your PDS") + } + + if par.State == "" { + return nil, echo.NewHTTPError(http.StatusBadRequest, "state is required") + } + + if par.Scope != o.scope { + return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid scope (expected %s, got %s)", o.scope, par.Scope)) + } + + urn := makeURN(jkt) + + newNonce := makeNonce() + + err = o.updateOAuthSession(jkt, &OAuthSession{ + DownstreamDPoPJKT: jkt, + DownstreamDPoPNonce: newNonce, + DownstreamPARRequestURI: urn, + DownstreamCodeChallenge: par.CodeChallenge, + DownstreamState: par.State, + DownstreamRedirectURI: par.RedirectURI, + Handle: par.LoginHint, + }) + if err != nil { + return nil, fmt.Errorf("could not create oauth session: %w", err) + } + c.Response().Header().Set("DPoP-Nonce", newNonce) + + resp := &PARResponse{ + RequestURI: urn, + ExpiresIn: int(dpopTimeWindow.Seconds()), + } + + return resp, nil +} diff --git a/pkg/oproxy/oauth_2_authorize.go b/pkg/oproxy/oauth_2_authorize.go new file mode 100644 index 00000000..794b43e4 --- /dev/null +++ b/pkg/oproxy/oauth_2_authorize.go @@ -0,0 +1,165 @@ +package oproxy + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" + + oauth "github.com/haileyok/atproto-oauth-golang" + "github.com/haileyok/atproto-oauth-golang/helpers" + "github.com/labstack/echo/v4" + "go.opentelemetry.io/otel" +) + +func (o *OProxy) HandleOAuthAuthorize(c echo.Context) error { + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandleOAuthAuthorize") + defer span.End() + c.Response().Header().Set("Access-Control-Allow-Origin", "*") + requestURI := c.QueryParam("request_uri") + if requestURI == "" { + return echo.NewHTTPError(http.StatusBadRequest, "request_uri is required") + } + clientID := c.QueryParam("client_id") + if clientID == "" { + return echo.NewHTTPError(http.StatusBadRequest, "client_id is required") + } + redirectURL, err := o.Authorize(ctx, requestURI, clientID) + if err != nil { + // we're a redirect; if we fail we need to send the user back + jkt, _, err := parseURN(requestURI) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to parse URN: %s", err)) + } + + session, err := o.loadOAuthSession(jkt) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to load OAuth session jkt=%s: %s", jkt, err)) + } + + u, err := url.Parse(session.DownstreamRedirectURI) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to parse downstream redirect URI: %s", err)) + } + q := u.Query() + q.Set("error", "authorize_failed") + q.Set("error_description", err.Error()) + u.RawQuery = q.Encode() + return c.Redirect(http.StatusTemporaryRedirect, u.String()) + } + return c.Redirect(http.StatusTemporaryRedirect, redirectURL) +} + +// downstream --> upstream transition; attempt to send user to the upstream auth server +func (o *OProxy) Authorize(ctx context.Context, requestURI, clientID string) (string, *echo.HTTPError) { + downstreamMeta, err := o.GetDownstreamMetadata("") + if err != nil { + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to get downstream metadata: %s", err)) + } + if downstreamMeta.ClientID != clientID { + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("client ID mismatch: %s != %s", downstreamMeta.ClientID, clientID)) + } + + jkt, _, err := parseURN(requestURI) + if err != nil { + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to parse URN: %s", err)) + } + + session, err := o.loadOAuthSession(jkt) + if err != nil { + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to load OAuth session jkt=%s: %s", jkt, err)) + } + + if session == nil { + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("no session found for jkt=%s", jkt)) + } + + if session.Status() != OAuthSessionStatePARCreated { + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("session is not in par-created state: %s", session.Status())) + } + + if session.DownstreamPARRequestURI != requestURI { + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("request URI mismatch: %s != %s", session.DownstreamPARRequestURI, requestURI)) + } + + now := time.Now() + session.DownstreamPARUsedAt = &now + err = o.updateOAuthSession(jkt, session) + if err != nil { + return "", echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to update OAuth session: %s", err)) + } + + upstreamMeta := o.GetUpstreamMetadata() + oclient, err := oauth.NewClient(oauth.ClientArgs{ + ClientJwk: o.upstreamJWK, + ClientId: upstreamMeta.ClientID, + RedirectUri: upstreamMeta.RedirectURIs[0], + }) + if err != nil { + return "", echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to create OAuth client: %s", err)) + } + + did, err := ResolveHandle(ctx, session.Handle) + if err != nil { + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to resolve handle '%s': %s", session.DID, err)) + } + + service, err := ResolveService(ctx, did) + if err != nil { + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to resolve service for DID '%s': %s", did, err)) + } + + authserver, err := oclient.ResolvePdsAuthServer(ctx, service) + if err != nil { + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to resolve PDS auth server for service '%s': %s", service, err)) + } + + authmeta, err := oclient.FetchAuthServerMetadata(ctx, authserver) + if err != nil { + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to fetch auth server metadata from '%s': %s", authserver, err)) + } + + k, err := helpers.GenerateKey(nil) + if err != nil { + return "", echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to generate DPoP key: %s", err)) + } + + state := makeState(jkt) + + opts := oauth.ParAuthRequestOpts{ + State: state, + } + parResp, err := oclient.SendParAuthRequest(ctx, authserver, authmeta, session.Handle, upstreamMeta.Scope, k, opts) + if err != nil { + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to send PAR auth request to '%s': %s", authserver, err)) + } + + jwkJSON, err := json.Marshal(k) + if err != nil { + return "", echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to marshal DPoP key to JSON: %s", err)) + } + + u, err := url.Parse(authmeta.AuthorizationEndpoint) + if err != nil { + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to parse auth server metadata: %s", err)) + } + u.RawQuery = fmt.Sprintf("client_id=%s&request_uri=%s", url.QueryEscape(upstreamMeta.ClientID), parResp.RequestUri) + str := u.String() + + session.DID = did + session.PDSUrl = service + session.UpstreamState = parResp.State + session.UpstreamAuthServerIssuer = authserver + session.UpstreamPKCEVerifier = parResp.PkceVerifier + session.UpstreamDPoPNonce = parResp.DpopAuthserverNonce + session.UpstreamDPoPPrivateJWK = string(jwkJSON) + + err = o.updateOAuthSession(jkt, session) + if err != nil { + return "", echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to update OAuth session: %s", err)) + } + + return str, nil +} diff --git a/pkg/oproxy/oauth_3_return.go b/pkg/oproxy/oauth_3_return.go new file mode 100644 index 00000000..7c17b92d --- /dev/null +++ b/pkg/oproxy/oauth_3_return.go @@ -0,0 +1,156 @@ +package oproxy + +import ( + "context" + "fmt" + "net/http" + "net/url" + "time" + + "github.com/bluesky-social/indigo/api/atproto" + "github.com/bluesky-social/indigo/xrpc" + oauth "github.com/haileyok/atproto-oauth-golang" + "github.com/labstack/echo/v4" + "github.com/lestrrat-go/jwx/v2/jwk" + "go.opentelemetry.io/otel" +) + +func (o *OProxy) HandleOAuthReturn(c echo.Context) error { + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandleOAuthReturn") + defer span.End() + code := c.QueryParam("code") + iss := c.QueryParam("iss") + state := c.QueryParam("state") + errorCode := c.QueryParam("error") + errorDescription := c.QueryParam("error_description") + var httpError *echo.HTTPError + var redirectURL string + if errorCode != "" { + httpError = echo.NewHTTPError(http.StatusBadRequest, fmt.Errorf("%s (%s)", errorDescription, errorCode)) + } else { + redirectURL, httpError = o.Return(ctx, code, iss, state) + } + if httpError != nil { + // we're a redirect; if we fail we need to send the user back + jkt, _, err := parseState(state) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to parse URN: %s", err)) + } + + session, err := o.loadOAuthSession(jkt) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to load OAuth session jkt=%s: %s", jkt, err)) + } + + u, err := url.Parse(session.DownstreamRedirectURI) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to parse downstream redirect URI: %s", err)) + } + q := u.Query() + q.Set("error", "return_failed") + q.Set("error_description", httpError.Error()) + u.RawQuery = q.Encode() + return c.Redirect(http.StatusTemporaryRedirect, u.String()) + } + return c.Redirect(http.StatusTemporaryRedirect, redirectURL) +} + +func (o *OProxy) Return(ctx context.Context, code string, iss string, state string) (string, *echo.HTTPError) { + upstreamMeta := o.GetUpstreamMetadata() + oclient, err := oauth.NewClient(oauth.ClientArgs{ + ClientJwk: o.upstreamJWK, + ClientId: upstreamMeta.ClientID, + RedirectUri: upstreamMeta.RedirectURIs[0], + }) + + jkt, _, err := parseState(state) + if err != nil { + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to parse state: %s", err)) + } + + session, err := o.loadOAuthSession(jkt) + if err != nil { + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to get OAuth session: %s", err)) + } + if session == nil { + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("no OAuth session found for state: %s", state)) + } + + if session.Status() != OAuthSessionStateUpstream { + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("session is not in upstream state: %s", session.Status())) + } + + if session.UpstreamState != state { + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("state mismatch: %s != %s", session.UpstreamState, state)) + } + + if iss != session.UpstreamAuthServerIssuer { + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("issuer mismatch: %s != %s", iss, session.UpstreamAuthServerIssuer)) + } + + key, err := jwk.ParseKey([]byte(session.UpstreamDPoPPrivateJWK)) + if err != nil { + return "", echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to parse DPoP private JWK: %s", err)) + } + + itResp, err := oclient.InitialTokenRequest(ctx, code, iss, session.UpstreamPKCEVerifier, session.UpstreamDPoPNonce, key) + if err != nil { + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to request initial token: %s", err)) + } + now := time.Now() + + if itResp.Sub != session.DID { + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("sub mismatch: %s != %s", itResp.Sub, session.DID)) + } + + if itResp.Scope != upstreamMeta.Scope { + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("scope mismatch: %s != %s", itResp.Scope, upstreamMeta.Scope)) + } + + downstreamCode, err := generateAuthorizationCode() + if err != nil { + return "", echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to generate downstream code: %s", err)) + } + + expiry := now.Add(time.Second * time.Duration(itResp.ExpiresIn)).UTC() + session.UpstreamAccessToken = itResp.AccessToken + session.UpstreamAccessTokenExp = &expiry + session.UpstreamRefreshToken = itResp.RefreshToken + session.DownstreamAuthorizationCode = downstreamCode + + authArgs := &oauth.XrpcAuthedRequestArgs{ + Did: session.DID, + AccessToken: session.UpstreamAccessToken, + PdsUrl: session.PDSUrl, + Issuer: session.UpstreamAuthServerIssuer, + DpopPdsNonce: session.UpstreamDPoPNonce, + DpopPrivateJwk: key, + } + + xrpcClient := &oauth.XrpcClient{ + OnDpopPdsNonceChanged: func(did, newNonce string) {}, + } + + // brief check to make sure we can actually do stuff + var out atproto.ServerCheckAccountStatus_Output + if err := xrpcClient.Do(ctx, authArgs, xrpc.Query, "application/json", "com.atproto.server.checkAccountStatus", nil, nil, &out); err != nil { + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to check account status: %s", err)) + } + + err = o.updateOAuthSession(session.DownstreamDPoPJKT, session) + if err != nil { + return "", echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to update OAuth session: %s", err)) + } + + u, err := url.Parse(session.DownstreamRedirectURI) + if err != nil { + return "", echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed to parse downstream redirect URI: %s", err)) + } + q := u.Query() + q.Set("iss", fmt.Sprintf("https://%s", o.host)) + q.Set("state", session.DownstreamState) + q.Set("code", session.DownstreamAuthorizationCode) + u.RawQuery = q.Encode() + + return u.String(), nil +} diff --git a/pkg/oproxy/oauth_4_token.go b/pkg/oproxy/oauth_4_token.go new file mode 100644 index 00000000..b46d0d21 --- /dev/null +++ b/pkg/oproxy/oauth_4_token.go @@ -0,0 +1,221 @@ +package oproxy + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" + + "github.com/AxisCommunications/go-dpop" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "go.opentelemetry.io/otel" +) + +type TokenRequest struct { + GrantType string `json:"grant_type"` + RedirectURI string `json:"redirect_uri"` + Code string `json:"code"` + CodeVerifier string `json:"code_verifier"` + ClientID string `json:"client_id"` + RefreshToken string `json:"refresh_token"` +} + +type RevokeRequest struct { + Token string `json:"token"` + ClientID string `json:"client_id"` +} + +type TokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + RefreshToken string `json:"refresh_token"` + Scope string `json:"scope"` + ExpiresIn int `json:"expires_in"` + Sub string `json:"sub"` +} + +var OAuthTokenExpiry = time.Hour * 24 + +var dpopTimeWindow = time.Duration(30 * time.Second) + +func (o *OProxy) HandleOAuthToken(c echo.Context) error { + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandleOAuthToken") + defer span.End() + var tokenRequest TokenRequest + if err := json.NewDecoder(c.Request().Body).Decode(&tokenRequest); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid request: %s", err)) + } + + dpopHeader := c.Request().Header.Get("DPoP") + if dpopHeader == "" { + return echo.NewHTTPError(http.StatusUnauthorized, "DPoP header is required") + } + + res, err := o.Token(ctx, &tokenRequest, dpopHeader) + if err != nil { + return err + } + jkt, _, err := getJKT(dpopHeader) + if err != nil { + return err + } + sess, err := o.loadOAuthSession(jkt) + if err != nil { + return err + } + sess.DownstreamDPoPNonce = makeNonce() + err = o.updateOAuthSession(sess.DownstreamDPoPJKT, sess) + if err != nil { + return err + } + c.Response().Header().Set("DPoP-Nonce", sess.DownstreamDPoPNonce) + + return c.JSON(http.StatusOK, res) +} + +func (o *OProxy) Token(ctx context.Context, tokenRequest *TokenRequest, dpopHeader string) (*TokenResponse, error) { + proof, err := dpop.Parse(dpopHeader, dpop.POST, &url.URL{Host: o.host, Scheme: "https", Path: "/oauth/token"}, dpop.ParseOptions{ + Nonce: "", + TimeWindow: &dpopTimeWindow, + }) + if err != nil { + return nil, echo.NewHTTPError(http.StatusBadRequest, "invalid DPoP proof") + } + + jkt := proof.PublicKey() + session, err := o.loadOAuthSession(jkt) + if err != nil { + return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("could not get oauth session: %s", err)) + } + + if tokenRequest.GrantType == "authorization_code" { + return o.AccessToken(ctx, tokenRequest, session) + } else if tokenRequest.GrantType == "refresh_token" { + return o.RefreshToken(ctx, tokenRequest, session) + } + return nil, echo.NewHTTPError(http.StatusBadRequest, "unsupported grant type") +} + +func (o *OProxy) AccessToken(ctx context.Context, tokenRequest *TokenRequest, session *OAuthSession) (*TokenResponse, error) { + if session.Status() != OAuthSessionStateDownstream { + return nil, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("session is not in downstream state: %s", session.Status())) + } + + // Hash the code verifier using SHA-256 + hasher := sha256.New() + hasher.Write([]byte(tokenRequest.CodeVerifier)) + codeChallenge := hasher.Sum(nil) + + encodedChallenge := base64.RawURLEncoding.WithPadding(base64.NoPadding).EncodeToString(codeChallenge) + + if session.DownstreamCodeChallenge != encodedChallenge { + return nil, fmt.Errorf("invalid code challenge") + } + + if session.DownstreamAuthorizationCode != tokenRequest.Code { + return nil, fmt.Errorf("invalid authorization code") + } + + accessToken, err := o.generateJWT(session) + if err != nil { + return nil, fmt.Errorf("could not generate access token: %w", err) + } + + refreshToken, err := generateRefreshToken() + if err != nil { + return nil, fmt.Errorf("could not generate refresh token: %w", err) + } + + session.DownstreamAccessToken = accessToken + session.DownstreamRefreshToken = refreshToken + + err = o.updateOAuthSession(session.DownstreamDPoPJKT, session) + if err != nil { + return nil, fmt.Errorf("could not update downstream session: %w", err) + } + + return &TokenResponse{ + AccessToken: accessToken, + TokenType: "DPoP", + RefreshToken: refreshToken, + Scope: "atproto transition:generic", + ExpiresIn: int(OAuthTokenExpiry.Seconds()), + Sub: session.DID, + }, nil +} + +func (o *OProxy) RefreshToken(ctx context.Context, tokenRequest *TokenRequest, session *OAuthSession) (*TokenResponse, error) { + + if session.Status() != OAuthSessionStateReady { + return nil, echo.NewHTTPError(http.StatusBadRequest, "session is not in ready state") + } + + if session.DownstreamRefreshToken != tokenRequest.RefreshToken { + return nil, echo.NewHTTPError(http.StatusBadRequest, "invalid refresh token") + } + + newJWT, err := o.generateJWT(session) + if err != nil { + return nil, fmt.Errorf("could not generate new access token: %w", err) + } + + session.DownstreamAccessToken = newJWT + err = o.updateOAuthSession(session.DownstreamDPoPJKT, session) + if err != nil { + return nil, fmt.Errorf("could not update downstream session: %w", err) + } + + return &TokenResponse{ + AccessToken: newJWT, + TokenType: "DPoP", + RefreshToken: session.DownstreamRefreshToken, + Scope: "atproto transition:generic", + ExpiresIn: int(OAuthTokenExpiry.Seconds()), + Sub: session.DID, + }, nil +} + +func (o *OProxy) generateJWT(session *OAuthSession) (string, error) { + uu, err := uuid.NewV7() + if err != nil { + return "", err + } + downstreamMeta, err := o.GetDownstreamMetadata("") + if err != nil { + return "", err + } + now := time.Now() + token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{ + "jti": uu.String(), + "sub": session.DID, + "exp": now.Add(OAuthTokenExpiry).Unix(), + "iat": now.Unix(), + "nbf": now.Unix(), + "cnf": map[string]any{ + "jkt": session.DownstreamDPoPJKT, + }, + "aud": fmt.Sprintf("did:web:%s", o.host), + "scope": downstreamMeta.Scope, + "client_id": downstreamMeta.ClientID, + "iss": fmt.Sprintf("https://%s", o.host), + }) + + var rawKey any + if err := o.downstreamJWK.Raw(&rawKey); err != nil { + return "", err + } + + tokenString, err := token.SignedString(rawKey) + + if err != nil { + return "", err + } + + return tokenString, nil +} diff --git a/pkg/oproxy/oauth_5_revoke.go b/pkg/oproxy/oauth_5_revoke.go new file mode 100644 index 00000000..134d402a --- /dev/null +++ b/pkg/oproxy/oauth_5_revoke.go @@ -0,0 +1,56 @@ +package oproxy + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" + + "github.com/AxisCommunications/go-dpop" + "github.com/labstack/echo/v4" + "go.opentelemetry.io/otel" +) + +func (o *OProxy) HandleOAuthRevoke(c echo.Context) error { + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandleOAuthRevoke") + defer span.End() + var revokeRequest RevokeRequest + if err := json.NewDecoder(c.Request().Body).Decode(&revokeRequest); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid request: %s", err)) + } + dpopHeader := c.Request().Header.Get("DPoP") + if dpopHeader == "" { + return echo.NewHTTPError(http.StatusUnauthorized, "DPoP header is required") + } + err := o.Revoke(ctx, dpopHeader, &revokeRequest) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("could not handle oauth revoke: %s", err)) + } + return c.JSON(http.StatusOK, map[string]interface{}{}) +} + +func (o *OProxy) Revoke(ctx context.Context, dpopHeader string, revokeRequest *RevokeRequest) error { + proof, err := dpop.Parse(dpopHeader, dpop.POST, &url.URL{Host: o.host, Scheme: "https", Path: "/oauth/revoke"}, dpop.ParseOptions{ + Nonce: "", + TimeWindow: &dpopTimeWindow, + }) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "invalid DPoP proof") + } + + session, err := o.loadOAuthSession(proof.PublicKey()) + if err != nil { + return fmt.Errorf("could not get downstream session: %w", err) + } + + now := time.Now() + session.RevokedAt = &now + err = o.updateOAuthSession(session.DownstreamDPoPJKT, session) + if err != nil { + return fmt.Errorf("could not update downstream session: %w", err) + } + + return nil +} diff --git a/pkg/oproxy/oauth_middleware.go b/pkg/oproxy/oauth_middleware.go new file mode 100644 index 00000000..6c147548 --- /dev/null +++ b/pkg/oproxy/oauth_middleware.go @@ -0,0 +1,234 @@ +package oproxy + +import ( + "context" + "crypto/ecdsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/AxisCommunications/go-dpop" + "github.com/golang-jwt/jwt/v5" + "github.com/labstack/echo/v4" +) + +var OAuthSessionContextKey = oauthSessionContextKeyType{} + +type oauthSessionContextKeyType struct{} + +var OProxyContextKey = oproxyContextKeyType{} + +type oproxyContextKeyType struct{} + +func GetOAuthSession(ctx context.Context) (*OAuthSession, *XrpcClient) { + o, ok := ctx.Value(OProxyContextKey).(*OProxy) + if !ok { + return nil, nil + } + session, ok := ctx.Value(OAuthSessionContextKey).(*OAuthSession) + if !ok { + return nil, nil + } + client, err := o.GetXrpcClient(session) + if err != nil { + return nil, nil + } + return session, client +} + +func (o *OProxy) OAuthMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // todo: see what these were set to before it got to us. + w.Header().Set("Access-Control-Allow-Origin", "*") // todo: ehhhhhhhhhhhh + w.Header().Set("Access-Control-Allow-Headers", "Content-Type,DPoP") + w.Header().Set("Access-Control-Allow-Methods", "*") + w.Header().Set("Access-Control-Expose-Headers", "DPoP-Nonce") + + ctx := r.Context() + session, err := o.getOAuthSession(r, w) + if err != nil { + if errors.Is(err, dpop.ErrIncorrectNonce) { + // w.Header().Set("WWW-Authenticate", `DPoP error="use_dpop_nonce", error_description="Invalid nonce"`) + w.Header().Set("content-type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + bs, _ := json.Marshal(map[string]interface{}{ + "error": "use_dpop_nonce", + "error_description": "Authorization server requires nonce in DPoP proof", + }) + w.Write(bs) + return + } + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(err.Error())) + return + } + if session == nil { + next.ServeHTTP(w, r) + return + } + ctx = context.WithValue(ctx, OAuthSessionContextKey, session) + ctx = context.WithValue(ctx, OProxyContextKey, o) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func getMethod(method string) (dpop.HTTPVerb, error) { + switch method { + case "POST": + return dpop.POST, nil + case "GET": + return dpop.GET, nil + } + return "", fmt.Errorf("invalid method") +} + +func (o *OProxy) getOAuthSession(r *http.Request, w http.ResponseWriter) (*OAuthSession, error) { + + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + return nil, nil + } + if !strings.HasPrefix(authHeader, "DPoP ") { + return nil, fmt.Errorf("invalid authorization header (must start with DPoP)") + } + token := strings.TrimPrefix(authHeader, "DPoP ") + + dpopHeader := r.Header.Get("DPoP") + if dpopHeader == "" { + return nil, fmt.Errorf("missing DPoP header") + } + + dpopMethod, err := getMethod(r.Method) + if err != nil { + return nil, fmt.Errorf("invalid method: %w", err) + } + + u, err := url.Parse(r.URL.String()) + if err != nil { + return nil, fmt.Errorf("invalid url: %w", err) + } + u.Scheme = "https" + u.Host = r.Host + u.RawQuery = "" + u.Fragment = "" + + jkt, nonce, err := getJKT(dpopHeader) + + session, err := o.loadOAuthSession(jkt) + if err != nil { + return nil, fmt.Errorf("could not get oauth session: %w", err) + } + if session == nil { + return nil, fmt.Errorf("oauth session not found") + } + if session.RevokedAt != nil { + return nil, fmt.Errorf("oauth session revoked") + } + if session.DownstreamDPoPNonce != nonce { + w.Header().Set("WWW-Authenticate", `DPoP algs="RS256 RS384 RS512 PS256 PS384 PS512 ES256 ES256K ES384 ES512", error="use_dpop_nonce", error_description="Authorization server requires nonce in DPoP proof"`) + w.Header().Set("DPoP-Nonce", session.DownstreamDPoPNonce) + return nil, dpop.ErrIncorrectNonce + } + + session.DownstreamDPoPNonce = makeNonce() + err = o.updateOAuthSession(session.DownstreamDPoPJKT, session) + if err != nil { + return nil, fmt.Errorf("could not update downstream session: %w", err) + } + w.Header().Set("DPoP-Nonce", session.DownstreamDPoPNonce) + + proof, err := dpop.Parse(dpopHeader, dpopMethod, u, dpop.ParseOptions{ + Nonce: nonce, + TimeWindow: &dpopTimeWindow, + }) + // Check the error type to determine response + if err != nil { + if ok := errors.Is(err, dpop.ErrInvalidProof); ok { + // Return 'invalid_dpop_proof' + return nil, fmt.Errorf("invalid DPoP proof: %w", err) + } + return nil, fmt.Errorf("error validating proof proof: %w", err) + } + + // Hash the token with base64 and SHA256 + // Get the access token JWT (introspect if needed) + // Parse the access token JWT and verify the signature + // Hash the access token with SHA-256 + hasher := sha256.New() + hasher.Write([]byte(token)) + hash := hasher.Sum(nil) + + // Encode the hash in URL-safe base64 format without padding + // accessTokenHash := base64.RawURLEncoding.EncodeToString(hash) + accessTokenHash := base64.RawURLEncoding.WithPadding(base64.NoPadding).EncodeToString(hash) + pubKey, err := o.downstreamJWK.PublicKey() + if err != nil { + return nil, fmt.Errorf("could not get access jwk public key: %w", err) + } + var pubKeyECDSA ecdsa.PublicKey + err = pubKey.Raw(&pubKeyECDSA) + if err != nil { + return nil, fmt.Errorf("could not get access jwk public key: %w", err) + } + + // Parse the access token JWT + claims := &dpop.BoundAccessTokenClaims{} + accessTokenJWT, err := jwt.ParseWithClaims(token, claims, func(token *jwt.Token) (any, error) { + return &pubKeyECDSA, nil + }) + + if err != nil { + return nil, fmt.Errorf("could not parse access token: %w", err) + } + + err = proof.Validate([]byte(accessTokenHash), accessTokenJWT) + // Check the error type to determine response + if err != nil { + return nil, fmt.Errorf("invalid proof: %w", err) + } + + return session, nil +} + +func (o *OProxy) DPoPNonceMiddleware(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + dpopHeader := c.Request().Header.Get("DPoP") + if dpopHeader == "" { + return echo.NewHTTPError(http.StatusBadRequest, "missing DPoP header") + } + + jkt, _, err := getJKT(dpopHeader) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + + session, err := o.loadOAuthSession(jkt) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + + c.Set("session", session) + return next(c) + } +} + +func (o *OProxy) ErrorHandlingMiddleware(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + err := next(c) + if err == nil { + return nil + } + httpError, ok := err.(*echo.HTTPError) + if ok { + o.slog.Error("oauth error", "code", httpError.Code, "message", httpError.Message, "internal", httpError.Internal) + return err + } + o.slog.Error("unhandled error", "error", err) + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } +} diff --git a/pkg/oproxy/oauth_session.go b/pkg/oproxy/oauth_session.go new file mode 100644 index 00000000..261f1345 --- /dev/null +++ b/pkg/oproxy/oauth_session.go @@ -0,0 +1,156 @@ +package oproxy + +import ( + "context" + "encoding/json" + "fmt" + "time" + + oauth "github.com/haileyok/atproto-oauth-golang" + "github.com/lestrrat-go/jwx/v2/jwk" +) + +var refreshWhenRemaining = time.Minute * 59 + +// OAuthSession stores authentication data needed during the OAuth flow +type OAuthSession struct { + DID string `json:"did" gorm:"column:repo_did;index"` + Handle string `json:"handle" gorm:"column:handle;index"` // possibly also did if they have no handle + PDSUrl string `json:"pds_url" gorm:"column:pds_url;index"` + + // Upstream fields + UpstreamState string `json:"upstream_state" gorm:"column:upstream_state;index"` + UpstreamAuthServerIssuer string `json:"upstream_auth_server_issuer" gorm:"column:upstream_auth_server_issuer"` + UpstreamPKCEVerifier string `json:"upstream_pkce_verifier" gorm:"column:upstream_pkce_verifier"` + UpstreamDPoPNonce string `json:"upstream_dpop_nonce" gorm:"column:upstream_dpop_nonce"` + UpstreamDPoPPrivateJWK string `json:"upstream_dpop_private_jwk" gorm:"column:upstream_dpop_private_jwk;type:text"` + UpstreamAccessToken string `json:"upstream_access_token" gorm:"column:upstream_access_token"` + UpstreamAccessTokenExp *time.Time `json:"upstream_access_token_exp" gorm:"column:upstream_access_token_exp"` + UpstreamRefreshToken string `json:"upstream_refresh_token" gorm:"column:upstream_refresh_token"` + + // Downstream fields + DownstreamDPoPNonce string `json:"downstream_dpop_nonce" gorm:"column:downstream_dpop_nonce"` + DownstreamDPoPJKT string `json:"downstream_dpop_jkt" gorm:"column:downstream_dpop_jkt;primaryKey"` + DownstreamAccessToken string `json:"downstream_access_token" gorm:"column:downstream_access_token;index"` + DownstreamRefreshToken string `json:"downstream_refresh_token" gorm:"column:downstream_refresh_token;index"` + DownstreamAuthorizationCode string `json:"downstream_authorization_code" gorm:"column:downstream_authorization_code;index"` + DownstreamState string `json:"downstream_state" gorm:"column:downstream_state"` + DownstreamScope string `json:"downstream_scope" gorm:"column:downstream_scope"` + DownstreamCodeChallenge string `json:"downstream_code_challenge" gorm:"column:downstream_code_challenge"` + DownstreamPARRequestURI string `json:"downstream_par_request_uri" gorm:"column:downstream_par_request_uri"` + DownstreamPARUsedAt *time.Time `json:"downstream_par_used_at" gorm:"column:downstream_par_used_at"` + DownstreamRedirectURI string `json:"downstream_redirect_uri" gorm:"column:downstream_redirect_uri"` + + RevokedAt *time.Time `json:"revoked_at" gorm:"column:revoked_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// for gorm. this is prettier than "o_auth_sessions" +func (o *OAuthSession) TableName() string { + return "oauth_sessions" +} + +type OAuthSessionStatus string + +const ( + // We've gotten the first request and sent it back for a new nonce + OAuthSessionStatePARPending OAuthSessionStatus = "par-pending" + // PAR has been created, but not yet used + OAuthSessionStatePARCreated OAuthSessionStatus = "par-created" + // PAR has been used, but maybe upstream will fail for some reason + OAuthSessionStatePARUsed OAuthSessionStatus = "par-used" + // PAR has been used, we're waiting to hear back from upstream + OAuthSessionStateUpstream OAuthSessionStatus = "upstream" + // Upstream came back, we've issued the user a code but it hasn't been used yet + OAuthSessionStateDownstream OAuthSessionStatus = "downstream" + // Code has been used, everything is good + OAuthSessionStateReady OAuthSessionStatus = "ready" + // For any reason we're done. Revoked or expired + OAuthSessionStateRejected OAuthSessionStatus = "rejected" +) + +func (o *OAuthSession) Status() OAuthSessionStatus { + if o.RevokedAt != nil { + return OAuthSessionStateRejected + } + if o.DownstreamAccessToken != "" { + return OAuthSessionStateReady + } + if o.DownstreamAuthorizationCode != "" { + return OAuthSessionStateDownstream + } + if o.UpstreamDPoPPrivateJWK != "" { + return OAuthSessionStateUpstream + } + if o.DownstreamPARUsedAt != nil { + return OAuthSessionStatePARUsed + } + if o.DownstreamPARRequestURI != "" { + return OAuthSessionStatePARCreated + } + if o.DownstreamDPoPNonce != "" { + return OAuthSessionStatePARPending + } + bs, _ := json.Marshal(o) + fmt.Printf("unknown oauth session status: %s\n", string(bs)) + // todo: this should never happen, log a warning? panic? + return OAuthSessionStateRejected +} + +func (o *OProxy) loadOAuthSession(jkt string) (*OAuthSession, error) { + session, err := o.userLoadOAuthSession(jkt) + if err != nil { + return nil, err + } + if session == nil { + return nil, nil + } + if session.Status() != OAuthSessionStateReady { + return session, nil + } + if session.UpstreamAccessTokenExp.Sub(time.Now()) > refreshWhenRemaining { + return session, nil + } + + upstreamMeta := o.GetUpstreamMetadata() + + oclient, err := oauth.NewClient(oauth.ClientArgs{ + ClientJwk: o.upstreamJWK, + ClientId: upstreamMeta.ClientID, + RedirectUri: upstreamMeta.RedirectURIs[0], + }) + + dpopKey, err := jwk.ParseKey([]byte(session.UpstreamDPoPPrivateJWK)) + if err != nil { + return nil, fmt.Errorf("failed to parse upstream dpop private key: %w", err) + } + + // refresh upstream before returning + resp, err := oclient.RefreshTokenRequest(context.Background(), session.UpstreamRefreshToken, session.UpstreamAuthServerIssuer, session.UpstreamDPoPNonce, dpopKey) + if err != nil { + // revoke, probably + o.slog.Error("failed to refresh upstream token, revoking downstream session", "error", err) + now := time.Now() + session.RevokedAt = &now + err = o.updateOAuthSession(session.DownstreamDPoPJKT, session) + if err != nil { + o.slog.Error("after upstream token refresh, failed to revoke downstream session", "error", err) + } + return nil, fmt.Errorf("failed to refresh upstream token: %w", err) + } + + exp := time.Now().Add(time.Second * time.Duration(resp.ExpiresIn)).UTC() + session.UpstreamAccessToken = resp.AccessToken + session.UpstreamAccessTokenExp = &exp + session.UpstreamRefreshToken = resp.RefreshToken + + err = o.updateOAuthSession(session.DownstreamDPoPJKT, session) + if err != nil { + return nil, fmt.Errorf("failed to update downstream session after upstream token refresh: %w", err) + } + + o.slog.Debug("refreshed upstream token", "session", session.DownstreamDPoPJKT) + + return session, nil +} diff --git a/pkg/oproxy/oproxy.go b/pkg/oproxy/oproxy.go new file mode 100644 index 00000000..067d3aef --- /dev/null +++ b/pkg/oproxy/oproxy.go @@ -0,0 +1,74 @@ +package oproxy + +import ( + "log/slog" + "net/http" + "os" + + "github.com/labstack/echo/v4" + "github.com/lestrrat-go/jwx/v2/jwk" +) + +type OProxy struct { + createOAuthSession func(id string, session *OAuthSession) error + updateOAuthSession func(id string, session *OAuthSession) error + userLoadOAuthSession func(id string) (*OAuthSession, error) + e *echo.Echo + host string + scope string + upstreamJWK jwk.Key + downstreamJWK jwk.Key + slog *slog.Logger +} + +type Config struct { + CreateOAuthSession func(id string, session *OAuthSession) error + UpdateOAuthSession func(id string, session *OAuthSession) error + LoadOAuthSession func(id string) (*OAuthSession, error) + Host string + Scope string + UpstreamJWK jwk.Key + DownstreamJWK jwk.Key + Slog *slog.Logger +} + +func New(conf *Config) *OProxy { + e := echo.New() + mySlog := conf.Slog + if mySlog == nil { + mySlog = slog.New(slog.NewTextHandler(os.Stderr, nil)) + } + o := &OProxy{ + createOAuthSession: conf.CreateOAuthSession, + updateOAuthSession: conf.UpdateOAuthSession, + userLoadOAuthSession: conf.LoadOAuthSession, + e: e, + host: conf.Host, + scope: conf.Scope, + upstreamJWK: conf.UpstreamJWK, + downstreamJWK: conf.DownstreamJWK, + slog: mySlog, + } + o.e.GET("/.well-known/oauth-authorization-server", o.HandleOAuthAuthorizationServer) + o.e.GET("/.well-known/oauth-protected-resource", o.HandleOAuthProtectedResource) + o.e.POST("/oauth/par", o.HandleOAuthPAR) + o.e.GET("/oauth/authorize", o.HandleOAuthAuthorize) + o.e.GET("/oauth/return", o.HandleOAuthReturn) + o.e.POST("/oauth/token", o.DPoPNonceMiddleware(o.HandleOAuthToken)) + o.e.POST("/oauth/revoke", o.DPoPNonceMiddleware(o.HandleOAuthRevoke)) + o.e.GET("/oauth/upstream/client-metadata.json", o.HandleClientMetadataUpstream) + o.e.GET("/oauth/upstream/jwks.json", o.HandleJwksUpstream) + o.e.GET("/oauth/downstream/client-metadata.json", o.HandleClientMetadataDownstream) + o.e.Use(o.ErrorHandlingMiddleware) + return o +} + +func (o *OProxy) Handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") // todo: ehhhhhhhhhhhh + w.Header().Set("Access-Control-Allow-Headers", "Content-Type,DPoP") + w.Header().Set("Access-Control-Allow-Methods", "*") + w.Header().Set("Access-Control-Expose-Headers", "DPoP-Nonce") + o.e.ServeHTTP(w, r) + }) +} diff --git a/pkg/oproxy/resolution.go b/pkg/oproxy/resolution.go new file mode 100644 index 00000000..55637953 --- /dev/null +++ b/pkg/oproxy/resolution.go @@ -0,0 +1,124 @@ +package oproxy + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "strings" + + "github.com/bluesky-social/indigo/atproto/syntax" +) + +// mostly borrowed from github.com/haileyok/atproto-oauth-golang, MIT license +func ResolveHandle(ctx context.Context, handle string) (string, error) { + var did string + + _, err := syntax.ParseHandle(handle) + if err != nil { + return "", err + } + + recs, err := net.LookupTXT(fmt.Sprintf("_atproto.%s", handle)) + if err == nil { + for _, rec := range recs { + if strings.HasPrefix(rec, "did=") { + did = strings.Split(rec, "did=")[1] + break + } + } + } + + if did == "" { + req, err := http.NewRequestWithContext( + ctx, + "GET", + fmt.Sprintf("https://%s/.well-known/atproto-did", handle), + nil, + ) + if err != nil { + return "", err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + io.Copy(io.Discard, resp.Body) + return "", fmt.Errorf("unable to resolve handle") + } + + b, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + maybeDid := string(b) + + if _, err := syntax.ParseDID(maybeDid); err != nil { + return "", fmt.Errorf("unable to resolve handle") + } + + did = maybeDid + } + + return did, nil +} + +func ResolveService(ctx context.Context, did string) (string, error) { + type Identity struct { + Service []struct { + ID string `json:"id"` + Type string `json:"type"` + ServiceEndpoint string `json:"serviceEndpoint"` + } `json:"service"` + } + + var ustr string + if strings.HasPrefix(did, "did:plc:") { + ustr = fmt.Sprintf("https://plc.directory/%s", did) + } else if strings.HasPrefix(did, "did:web:") { + ustr = fmt.Sprintf("https://%s/.well-known/did.json", strings.TrimPrefix(did, "did:web:")) + } else { + return "", fmt.Errorf("did was not a supported did type") + } + + req, err := http.NewRequestWithContext(ctx, "GET", ustr, nil) + if err != nil { + return "", err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + io.Copy(io.Discard, resp.Body) + return "", fmt.Errorf("could not find identity in plc registry") + } + + var identity Identity + if err := json.NewDecoder(resp.Body).Decode(&identity); err != nil { + return "", err + } + + var service string + for _, svc := range identity.Service { + if svc.ID == "#atproto_pds" { + service = svc.ServiceEndpoint + } + } + + if service == "" { + return "", fmt.Errorf("could not find atproto_pds service in identity services") + } + + return service, nil +} diff --git a/pkg/oproxy/token_generation.go b/pkg/oproxy/token_generation.go new file mode 100644 index 00000000..6aca1897 --- /dev/null +++ b/pkg/oproxy/token_generation.go @@ -0,0 +1,23 @@ +package oproxy + +import ( + "fmt" + + "github.com/google/uuid" +) + +func generateRefreshToken() (string, error) { + uu, err := uuid.NewV7() + if err != nil { + return "", err + } + return fmt.Sprintf("refresh-%s", uu.String()), nil +} + +func generateAuthorizationCode() (string, error) { + uu, err := uuid.NewV7() + if err != nil { + return "", err + } + return fmt.Sprintf("code-%s", uu.String()), nil +} diff --git a/pkg/oproxy/types.go b/pkg/oproxy/types.go new file mode 100644 index 00000000..e57776fa --- /dev/null +++ b/pkg/oproxy/types.go @@ -0,0 +1,35 @@ +package oproxy + +import "github.com/haileyok/atproto-oauth-golang/helpers" + +type OAuthClientMetadata struct { + RedirectURIs []string `json:"redirect_uris"` + ResponseTypes []string `json:"response_types,omitempty"` + GrantTypes []string `json:"grant_types,omitempty"` + Scope string `json:"scope,omitempty"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"` + TokenEndpointAuthSigningAlg string `json:"token_endpoint_auth_signing_alg,omitempty"` + UserinfoSignedResponseAlg string `json:"userinfo_signed_response_alg,omitempty"` + UserinfoEncryptedResponseAlg string `json:"userinfo_encrypted_response_alg,omitempty"` + JwksURI string `json:"jwks_uri,omitempty"` + ApplicationType string `json:"application_type,omitempty"` // "web" or "native" + SubjectType string `json:"subject_type,omitempty"` // "public" or "pairwise" + RequestObjectSigningAlg string `json:"request_object_signing_alg,omitempty"` + IDTokenSignedResponseAlg string `json:"id_token_signed_response_alg,omitempty"` + AuthorizationSignedResponseAlg string `json:"authorization_signed_response_alg,omitempty"` + AuthorizationEncryptedResponseEnc string `json:"authorization_encrypted_response_enc,omitempty"` + AuthorizationEncryptedResponseAlg string `json:"authorization_encrypted_response_alg,omitempty"` + ClientID string `json:"client_id,omitempty"` + ClientName string `json:"client_name,omitempty"` + ClientURI string `json:"client_uri,omitempty"` + PolicyURI string `json:"policy_uri,omitempty"` + TosURI string `json:"tos_uri,omitempty"` + LogoURI string `json:"logo_uri,omitempty"` + DefaultMaxAge int `json:"default_max_age,omitempty"` + RequireAuthTime *bool `json:"require_auth_time,omitempty"` + Contacts []string `json:"contacts,omitempty"` + TLSClientCertificateBoundAccessTokens *bool `json:"tls_client_certificate_bound_access_tokens,omitempty"` + DPoPBoundAccessTokens *bool `json:"dpop_bound_access_tokens,omitempty"` + AuthorizationDetailsTypes []string `json:"authorization_details_types,omitempty"` + Jwks *helpers.JwksResponseObject `json:"jwks,omitempty"` +} diff --git a/pkg/oproxy/xrpc_client.go b/pkg/oproxy/xrpc_client.go new file mode 100644 index 00000000..e57460b0 --- /dev/null +++ b/pkg/oproxy/xrpc_client.go @@ -0,0 +1,62 @@ +package oproxy + +import ( + "context" + "fmt" + "net/http" + + "github.com/bluesky-social/indigo/xrpc" + oauth "github.com/haileyok/atproto-oauth-golang" + "github.com/labstack/echo/v4" + "github.com/lestrrat-go/jwx/v2/jwk" +) + +var xrpcClient *oauth.XrpcClient + +type XrpcClient struct { + client *oauth.XrpcClient + authArgs *oauth.XrpcAuthedRequestArgs +} + +func (o *OProxy) GetXrpcClient(session *OAuthSession) (*XrpcClient, error) { + key, err := jwk.ParseKey([]byte(session.UpstreamDPoPPrivateJWK)) + if err != nil { + return nil, fmt.Errorf("failed to parse DPoP private JWK: %w", err) + } + authArgs := &oauth.XrpcAuthedRequestArgs{ + Did: session.DID, + AccessToken: session.UpstreamAccessToken, + PdsUrl: session.PDSUrl, + Issuer: session.UpstreamAuthServerIssuer, + DpopPdsNonce: session.UpstreamDPoPNonce, + DpopPrivateJwk: key, + } + + xrpcClient := &oauth.XrpcClient{ + OnDpopPdsNonceChanged: func(did, newNonce string) { + sess, err := o.loadOAuthSession(session.DownstreamDPoPJKT) + if err != nil { + o.slog.Error("failed to get OAuth session in OnDpopPdsNonceChanged", "error", err) + return + } + sess.UpstreamDPoPNonce = newNonce + err = o.updateOAuthSession(session.DownstreamDPoPJKT, sess) + if err != nil { + o.slog.Error("failed to update OAuth session in OnDpopPdsNonceChanged", "error", err) + } + }, + } + return &XrpcClient{client: xrpcClient, authArgs: authArgs}, nil +} + +func (c *XrpcClient) Do(ctx context.Context, kind xrpc.XRPCRequestType, inpenc, method string, params map[string]any, bodyobj any, out any) error { + err := c.client.Do(ctx, c.authArgs, kind, inpenc, method, params, bodyobj, out) + if err == nil { + return nil + } + xErr, ok := err.(*xrpc.Error) + if !ok { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + return echo.NewHTTPError(xErr.StatusCode, xErr.Error()) +} diff --git a/pkg/spxrpc/app_bsky_actor.go b/pkg/spxrpc/app_bsky_actor.go new file mode 100644 index 00000000..09a2ad8e --- /dev/null +++ b/pkg/spxrpc/app_bsky_actor.go @@ -0,0 +1,27 @@ +package spxrpc + +import ( + "context" + "net/http" + + appbskytypes "github.com/bluesky-social/indigo/api/bsky" + "github.com/bluesky-social/indigo/xrpc" + "github.com/labstack/echo/v4" + "stream.place/streamplace/pkg/oproxy" +) + +func (s *Server) handleAppBskyActorGetProfile(ctx context.Context, actor string) (*appbskytypes.ActorDefs_ProfileViewDetailed, error) { + session, client := oproxy.GetOAuthSession(ctx) + if session == nil { + return nil, echo.NewHTTPError(http.StatusUnauthorized, "oauth session not found") + } + + // brief check to make sure we can actually do stuff + var out appbskytypes.ActorDefs_ProfileViewDetailed + err := client.Do(ctx, xrpc.Query, "application/json", "app.bsky.actor.getProfile", map[string]any{"actor": actor}, nil, &out) + if err != nil { + return nil, err + } + + return &out, nil +} diff --git a/pkg/spxrpc/com_atproto_identity.go b/pkg/spxrpc/com_atproto_identity.go new file mode 100644 index 00000000..d48e73de --- /dev/null +++ b/pkg/spxrpc/com_atproto_identity.go @@ -0,0 +1,16 @@ +package spxrpc + +import ( + "context" + + comatprototypes "github.com/bluesky-social/indigo/api/atproto" + "stream.place/streamplace/pkg/oproxy" +) + +func (s *Server) handleComAtprotoIdentityResolveHandle(ctx context.Context, handle string) (*comatprototypes.IdentityResolveHandle_Output, error) { + did, err := oproxy.ResolveHandle(ctx, handle) + if err != nil { + return nil, err + } + return &comatprototypes.IdentityResolveHandle_Output{Did: did}, nil +} diff --git a/pkg/spxrpc/spxrpc.go b/pkg/spxrpc/spxrpc.go index c32ef702..6a540071 100644 --- a/pkg/spxrpc/spxrpc.go +++ b/pkg/spxrpc/spxrpc.go @@ -29,6 +29,12 @@ func NewServer(cli *config.CLI, model model.Model) (*Server, error) { if err != nil { return nil, err } + err = s.RegisterHandlersComAtproto(e) + if err != nil { + return nil, err + } + e.GET("/xrpc/*", s.HandleWildcard) + e.POST("/xrpc/*", s.HandleWildcard) return s, nil } diff --git a/pkg/spxrpc/stubs.go b/pkg/spxrpc/stubs.go index ab3b31b0..9be497a3 100644 --- a/pkg/spxrpc/stubs.go +++ b/pkg/spxrpc/stubs.go @@ -3,6 +3,7 @@ package spxrpc import ( "strconv" + comatprototypes "github.com/bluesky-social/indigo/api/atproto" appbskytypes "github.com/bluesky-social/indigo/api/bsky" "github.com/labstack/echo/v4" "go.opentelemetry.io/otel" @@ -10,10 +11,25 @@ import ( ) func (s *Server) RegisterHandlersAppBsky(e *echo.Echo) error { + e.GET("/xrpc/app.bsky.actor.getProfile", s.HandleAppBskyActorGetProfile) e.GET("/xrpc/app.bsky.feed.getFeedSkeleton", s.HandleAppBskyFeedGetFeedSkeleton) return nil } +func (s *Server) HandleAppBskyActorGetProfile(c echo.Context) error { + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandleAppBskyActorGetProfile") + defer span.End() + actor := c.QueryParam("actor") + var out *appbskytypes.ActorDefs_ProfileViewDetailed + var handleErr error + // func (s *Server) handleAppBskyActorGetProfile(ctx context.Context,actor string) (*appbskytypes.ActorDefs_ProfileViewDetailed, error) + out, handleErr = s.handleAppBskyActorGetProfile(ctx, actor) + if handleErr != nil { + return handleErr + } + return c.JSON(200, out) +} + func (s *Server) HandleAppBskyFeedGetFeedSkeleton(c echo.Context) error { ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandleAppBskyFeedGetFeedSkeleton") defer span.End() @@ -45,9 +61,24 @@ func (s *Server) RegisterHandlersChatBsky(e *echo.Echo) error { } func (s *Server) RegisterHandlersComAtproto(e *echo.Echo) error { + e.GET("/xrpc/com.atproto.identity.resolveHandle", s.HandleComAtprotoIdentityResolveHandle) return nil } +func (s *Server) HandleComAtprotoIdentityResolveHandle(c echo.Context) error { + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandleComAtprotoIdentityResolveHandle") + defer span.End() + handle := c.QueryParam("handle") + var out *comatprototypes.IdentityResolveHandle_Output + var handleErr error + // func (s *Server) handleComAtprotoIdentityResolveHandle(ctx context.Context,handle string) (*comatprototypes.IdentityResolveHandle_Output, error) + out, handleErr = s.handleComAtprotoIdentityResolveHandle(ctx, handle) + if handleErr != nil { + return handleErr + } + return c.JSON(200, out) +} + func (s *Server) RegisterHandlersPlaceStream(e *echo.Echo) error { e.GET("/xrpc/place.stream.graph.getFollowingUser", s.HandlePlaceStreamGraphGetFollowingUser) return nil diff --git a/pkg/spxrpc/wildcard.go b/pkg/spxrpc/wildcard.go new file mode 100644 index 00000000..4768f04b --- /dev/null +++ b/pkg/spxrpc/wildcard.go @@ -0,0 +1,57 @@ +package spxrpc + +import ( + "fmt" + "net/http" + "strings" + + "github.com/bluesky-social/indigo/xrpc" + "github.com/labstack/echo/v4" + "go.opentelemetry.io/otel" + "stream.place/streamplace/pkg/log" + "stream.place/streamplace/pkg/oproxy" +) + +func (s *Server) HandleWildcard(c echo.Context) error { + ctx, span := otel.Tracer("server").Start(c.Request().Context(), "HandleWildcard") + defer span.End() + + session, client := oproxy.GetOAuthSession(ctx) + if session == nil { + return echo.NewHTTPError(http.StatusUnauthorized, "oauth session not found") + } + + var out map[string]any + + // Get the last path segment in the URL + path := c.Request().URL.Path + segments := strings.Split(path, "/") + lastSegment := segments[len(segments)-1] + + var xrpcType xrpc.XRPCRequestType + var err error + if c.Request().Method == "GET" { + xrpcType = xrpc.Query + queryParams := make(map[string]any) + for k, v := range c.QueryParams() { + for _, vv := range v { + queryParams[k] = vv + } + } + err = client.Do(ctx, xrpcType, "application/json", lastSegment, queryParams, nil, &out) + } else { + xrpcType = xrpc.Procedure + var body map[string]any + if err := c.Bind(&body); err != nil { + return c.JSON(http.StatusBadRequest, xrpc.XRPCError{ErrStr: "BadRequest", Message: fmt.Sprintf("invalid body: %s", err)}) + } + err = client.Do(ctx, xrpcType, "application/json", lastSegment, nil, body, &out) + } + + if err != nil { + log.Error(ctx, "upstream xrpc error", "error", err) + return err + } + + return c.JSON(200, out) +} diff --git a/yarn.lock b/yarn.lock index e15e25f3..24e96dd6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -53,30 +53,6 @@ __metadata: languageName: node linkType: hard -"@aquareum/atproto-oauth-client-react-native@npm:^0.0.1": - version: 0.0.1 - resolution: "@aquareum/atproto-oauth-client-react-native@npm:0.0.1" - dependencies: - "@atproto-labs/did-resolver": "npm:0.1.5" - "@atproto-labs/handle-resolver-node": "npm:0.1.7" - "@atproto-labs/simple-store": "npm:0.1.1" - "@atproto-labs/simple-store-memory": "npm:0.1.1" - "@atproto/did": "npm:0.1.3" - "@atproto/jwk": "npm:0.1.1" - "@atproto/jwk-jose": "npm:0.1.2" - "@atproto/jwk-webcrypto": "npm:0.1.2" - "@atproto/oauth-client": "npm:0.3.2" - "@atproto/oauth-client-browser": "npm:0.3.2" - "@atproto/oauth-types": "npm:0.2.1" - abortcontroller-polyfill: "npm:^1.7.6" - event-target-shim: "npm:^6.0.2" - expo-sqlite: "npm:^15.0.3" - jose: "npm:^5.2.0" - react-native-quick-crypto: "npm:^0.7.7" - checksum: 10/73263e06756f8acfc526a6e89d5e68df9b2930b839ba6748a498f1b7e5d5480d31e068037f95bd7b7d1611be8565da80f045d85506ced7d79e4e132f6182a371 - languageName: node - linkType: hard - "@astrojs/compiler@npm:^2.11.0": version: 2.12.0 resolution: "@astrojs/compiler@npm:2.12.0" @@ -8807,6 +8783,31 @@ __metadata: languageName: node linkType: hard +"@streamplace/atproto-oauth-client-react-native@workspace:*, @streamplace/atproto-oauth-client-react-native@workspace:js/atproto-oauth-client-react-native": + version: 0.0.0-use.local + resolution: "@streamplace/atproto-oauth-client-react-native@workspace:js/atproto-oauth-client-react-native" + dependencies: + "@atproto-labs/did-resolver": "npm:0.1.5" + "@atproto-labs/handle-resolver-node": "npm:0.1.7" + "@atproto-labs/simple-store": "npm:0.1.1" + "@atproto-labs/simple-store-memory": "npm:0.1.1" + "@atproto/did": "npm:0.1.3" + "@atproto/jwk": "npm:0.1.1" + "@atproto/jwk-jose": "npm:0.1.2" + "@atproto/jwk-webcrypto": "npm:0.1.2" + "@atproto/oauth-client": "npm:0.3.2" + "@atproto/oauth-client-browser": "npm:0.3.2" + "@atproto/oauth-types": "npm:0.2.1" + "@types/node": "npm:^22.10.1" + abortcontroller-polyfill: "npm:^1.7.6" + event-target-shim: "npm:^6.0.2" + expo-sqlite: "npm:^15.0.3" + jose: "npm:^5.2.0" + react-native-quick-crypto: "npm:^0.7.7" + typescript: "npm:^5.6.3" + languageName: unknown + linkType: soft + "@streamplace/config-react-native-webrtc@workspace:js/config-react-native-webrtc": version: 0.0.0-use.local resolution: "@streamplace/config-react-native-webrtc@workspace:js/config-react-native-webrtc" @@ -11133,6 +11134,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^22.10.1": + version: 22.15.17 + resolution: "@types/node@npm:22.15.17" + dependencies: + undici-types: "npm:~6.21.0" + checksum: 10/3f5870ec1ac16b1dd8e5817de81164df9b69e4cf19cce692cb7c9b1af1deaecfd98b591b56155fcc4aa582f7189a4fc0c8d7d3226fa0387403db615a12dd8cb6 + languageName: node + linkType: hard + "@types/normalize-package-data@npm:^2.4.0": version: 2.4.4 resolution: "@types/normalize-package-data@npm:2.4.4" @@ -29556,7 +29566,6 @@ __metadata: version: 0.0.0-use.local resolution: "streamplace@workspace:js/app" dependencies: - "@aquareum/atproto-oauth-client-react-native": "npm:^0.0.1" "@atproto-labs/pipe": "npm:^0.1.0" "@atproto/crypto": "npm:^0.4.2" "@atproto/jwk-jose": "npm:^0.1.2" @@ -29581,6 +29590,7 @@ __metadata: "@react-navigation/native": "npm:^6.1.18" "@react-navigation/native-stack": "npm:^6.11.0" "@reduxjs/toolkit": "npm:^2.3.0" + "@streamplace/atproto-oauth-client-react-native": "workspace:*" "@tamagui/babel-plugin": "npm:^1.123.17" "@tamagui/config": "npm:^1.123.17" "@tamagui/lucide-icons": "npm:^1.123.17" @@ -30946,6 +30956,16 @@ __metadata: languageName: node linkType: hard +"typescript@npm:^5.6.3": + version: 5.8.3 + resolution: "typescript@npm:5.8.3" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10/65c40944c51b513b0172c6710ee62e951b70af6f75d5a5da745cb7fab132c09ae27ffdf7838996e3ed603bb015dadd099006658046941bd0ba30340cc563ae92 + languageName: node + linkType: hard + "typescript@npm:^5.7.2": version: 5.7.3 resolution: "typescript@npm:5.7.3" @@ -30986,6 +31006,16 @@ __metadata: languageName: node linkType: hard +"typescript@patch:typescript@npm%3A^5.6.3#optional!builtin": + version: 5.8.3 + resolution: "typescript@patch:typescript@npm%3A5.8.3#optional!builtin::version=5.8.3&hash=b45daf" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10/98470634034ec37fd9ea61cc82dcf9a27950d0117a4646146b767d085a2ec14b137aae9642a83d1c62732d7fdcdac19bb6288b0bb468a72f7a06ae4e1d2c72c9 + languageName: node + linkType: hard + "typescript@patch:typescript@npm%3A^5.7.2#optional!builtin": version: 5.7.3 resolution: "typescript@patch:typescript@npm%3A5.7.3#optional!builtin::version=5.7.3&hash=b45daf" @@ -31140,6 +31170,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~6.21.0": + version: 6.21.0 + resolution: "undici-types@npm:6.21.0" + checksum: 10/ec8f41aa4359d50f9b59fa61fe3efce3477cc681908c8f84354d8567bb3701fafdddf36ef6bff307024d3feb42c837cf6f670314ba37fc8145e219560e473d14 + languageName: node + linkType: hard + "undici@npm:6.19.7": version: 6.19.7 resolution: "undici@npm:6.19.7"