diff --git a/js/app/components/access/node-session-required.tsx b/js/app/components/access/node-session-required.tsx index fb81ce083..20d098090 100644 --- a/js/app/components/access/node-session-required.tsx +++ b/js/app/components/access/node-session-required.tsx @@ -1,7 +1,6 @@ import { Button, Text, - useAccessStatus, useNetworkName, useTheme, } from "@streamplace/components"; @@ -20,17 +19,6 @@ export function useBearerSession(): boolean { return kind === "brokered" || kind === "credential"; } -/** - * Whether a screen that writes as the streamer must first get a node OAuth - * session: the viewer holds only a bearer session, and the node has no - * credentials of its own for the account (access status nodeSession). - */ -export function useNeedsNodeSession(): boolean { - const bearer = useBearerSession(); - const nodeSession = useAccessStatus()?.nodeSession ?? false; - return bearer && !nodeSession; -} - /** * Shown in place of a screen that needs a node OAuth session when the * viewer only holds a bearer session. The button opens the login modal on diff --git a/js/app/src/screens/live-dashboard.tsx b/js/app/src/screens/live-dashboard.tsx index 8ea8b25dd..97a6199cd 100644 --- a/js/app/src/screens/live-dashboard.tsx +++ b/js/app/src/screens/live-dashboard.tsx @@ -2,7 +2,7 @@ import { useRoute } from "@react-navigation/native"; import { LivestreamProvider, PlayerProvider } from "@streamplace/components"; import { NodeSessionRequired, - useNeedsNodeSession, + useBearerSession, } from "components/access/node-session-required"; import BentoGrid from "components/live-dashboard/bento-grid"; import Loading from "components/loading/loading"; @@ -17,7 +17,7 @@ export default function LiveDashboard() { const userProfile = useUserProfile(); const isLive = useLiveUser(); const openLoginModal = useStore((state) => state.openLoginModal); - const bearerSession = useNeedsNodeSession(); + const bearerSession = useBearerSession(); const route = useRoute(); const [videoElement, setVideoElement] = useState( null, diff --git a/js/app/src/screens/mobile-go-live.tsx b/js/app/src/screens/mobile-go-live.tsx index 18bde1e09..8981e3e13 100644 --- a/js/app/src/screens/mobile-go-live.tsx +++ b/js/app/src/screens/mobile-go-live.tsx @@ -2,7 +2,7 @@ import { useRoute } from "@react-navigation/native"; import { KeepAwake } from "@streamplace/components"; import { NodeSessionRequired, - useNeedsNodeSession, + useBearerSession, } from "components/access/node-session-required"; import Loading from "components/loading/loading"; import { Player } from "components/mobile/player"; @@ -14,7 +14,7 @@ import { useUserProfile } from "store/hooks"; export default function MobileGoLive() { const userProfile = useUserProfile(); const openLoginModal = useStore((state) => state.openLoginModal); - const bearerSession = useNeedsNodeSession(); + const bearerSession = useBearerSession(); const route = useRoute(); useEffect(() => { diff --git a/js/app/store/slices/blueskySlice.ts b/js/app/store/slices/blueskySlice.ts index abd0976f7..d4fae5a97 100644 --- a/js/app/store/slices/blueskySlice.ts +++ b/js/app/store/slices/blueskySlice.ts @@ -27,6 +27,16 @@ import { OAuthClient } from "../../features/bluesky/oauthClientImport"; import { withoutBlueskyScopes } from "../../features/bluesky/scopes"; import { DID_KEY, STORED_KEY_KEY, StreamKey } from "./baseSlice"; +// An agent on the node's OAuth session, told what to do when the node +// rejects that session: sign out and ask for a sign-in. +function oauthAgent(session: OAuthSession, slice: () => BlueskySlice) { + const agent = new StreamplaceAgent(session); + agent.onSessionRejected = (reason) => { + void slice().sessionRejected(reason); + }; + return agent; +} + // Where a credentials session (tokens this app holds) is kept between loads. const CREDENTIAL_SESSION_KEY = "sp:credential-session"; @@ -124,6 +134,10 @@ export interface BlueskySlice { * refresh): like logout, but never throws and never waits on the * provider — the point is to get back to an anonymous viewer. */ dropSession: () => Promise; + /** The node rejected the OAuth session (revoked, or gone from its store): + * drop it and open the login modal on the OAuth flow, instead of leaving + * a signed-in-looking app whose every authenticated call fails. */ + sessionRejected: (reason: string) => Promise; getProfile: (actor: string) => Promise; getProfiles: (actors: string[]) => Promise; oauthCallback: (url: string) => Promise; @@ -409,7 +423,7 @@ export const createBlueskySlice: StateCreator< authStatus: "loggedIn", oauthSession: session, sessionKind: "oauth", - pdsAgent: new StreamplaceAgent(session), + pdsAgent: oauthAgent(session, () => get() as BlueskySlice), anonPDSAgent, }); void (get() as BlueskySlice).refreshSessionScope(); @@ -574,6 +588,13 @@ export const createBlueskySlice: StateCreator< }); }, + sessionRejected: async (reason: string) => { + const state = get() as BlueskySlice; + if (state.sessionKind !== "oauth" || !state.oauthSession) return; + console.warn("the node rejected the OAuth session; signing out", reason); + await state.dropSession(); + (get() as BlueskySlice).openLoginModal(undefined, { oauth: true }); + }, dropSession: async () => { const state = get() as BlueskySlice; const session = state.oauthSession; @@ -684,7 +705,7 @@ export const createBlueskySlice: StateCreator< client, oauthSession: ret.session, sessionKind: "oauth", - pdsAgent: new StreamplaceAgent(ret.session), + pdsAgent: oauthAgent(ret.session, () => get() as BlueskySlice), authStatus: "loggedIn", }); void (get() as BlueskySlice).refreshSessionScope(); diff --git a/js/components/src/streamplace-store/access.tsx b/js/components/src/streamplace-store/access.tsx index 9c5e7a7ac..0fd6fe9f0 100644 --- a/js/components/src/streamplace-store/access.tsx +++ b/js/components/src/streamplace-store/access.tsx @@ -20,9 +20,6 @@ export interface AccessStatus { /** Whether the account lives on the network's own PDS; undefined when * the node has no network PDS configured. */ networkMember?: boolean; - /** The node holds credentials for this account and writes its records - * itself, so a bearer (PDS) session is enough to go live here. */ - nodeSession?: boolean; } // What we assume when the node predates access control (the method doesn't @@ -114,7 +111,6 @@ export function useFetchAccessStatus() { chatVerifiedOnly: res.chatVerifiedOnly ?? false, chatVerified: res.chatVerified ?? false, networkMember: res.networkMember ?? undefined, - nodeSession: (res as any).nodeSession ?? false, }, accessStatusLoaded: true, accessStatusError: null, diff --git a/js/docs/src/content/docs/lex-reference/access/place-stream-access-getstatus.md b/js/docs/src/content/docs/lex-reference/access/place-stream-access-getstatus.md index 9c1d2532d..3c6b9d619 100644 --- a/js/docs/src/content/docs/lex-reference/access/place-stream-access-getstatus.md +++ b/js/docs/src/content/docs/lex-reference/access/place-stream-access-getstatus.md @@ -28,16 +28,15 @@ Report the caller's roles on this node and the node's access policy. Works unaut **Schema Type:** `object` -| Name | Type | Req'd | Description | Constraints | -| ------------------ | ------------------------------------------------------------------------------------------- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | -| `did` | `string` | ❌ | The authenticated caller, when there is one. | Format: `did` | -| `roles` | Array of [`place.stream.access.defs#role`](/lex-reference/place-stream-access-defs#role) | ✅ | Every role the caller effectively holds. | | -| `policy` | [`place.stream.access.defs#policyView`](/lex-reference/place-stream-access-defs#policyview) | ✅ | | | -| `space` | `string` | ✅ | The node's access-control space: at://{authority}/space/place.stream.access.control/self (A space URI; not validated as a classic at-uri because the space form is newer than that grammar.) | | -| `chatVerifiedOnly` | `boolean` | ❌ | Whether chat is restricted to users verified by the node's trusted verifiers (branding key chatVerifiedOnly). | | -| `chatVerified` | `boolean` | ❌ | Whether the authenticated caller is verified by one of the node's trusted verifiers. | | -| `networkMember` | `boolean` | ❌ | Whether the caller's (or subject's) account is hosted on the network's own PDS (branding key loginPdsUrl). Absent when the node has no such PDS configured. | | -| `nodeSession` | `boolean` | ❌ | Whether this node can write to the caller's (or subject's) repo on its own: it holds credentials for the account (--account-credentials), so a session the node can't attribute (a sign-in against the PDS) is enough to go live here. | | +| Name | Type | Req'd | Description | Constraints | +| ------------------ | ------------------------------------------------------------------------------------------- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | +| `did` | `string` | ❌ | The authenticated caller, when there is one. | Format: `did` | +| `roles` | Array of [`place.stream.access.defs#role`](/lex-reference/place-stream-access-defs#role) | ✅ | Every role the caller effectively holds. | | +| `policy` | [`place.stream.access.defs#policyView`](/lex-reference/place-stream-access-defs#policyview) | ✅ | | | +| `space` | `string` | ✅ | The node's access-control space: at://{authority}/space/place.stream.access.control/self (A space URI; not validated as a classic at-uri because the space form is newer than that grammar.) | | +| `chatVerifiedOnly` | `boolean` | ❌ | Whether chat is restricted to users verified by the node's trusted verifiers (branding key chatVerifiedOnly). | | +| `chatVerified` | `boolean` | ❌ | Whether the authenticated caller is verified by one of the node's trusted verifiers. | | +| `networkMember` | `boolean` | ❌ | Whether the caller's (or subject's) account is hosted on the network's own PDS (branding key loginPdsUrl). Absent when the node has no such PDS configured. | | --- @@ -99,10 +98,6 @@ Report the caller's roles on this node and the node's access policy. Works unaut "networkMember": { "type": "boolean", "description": "Whether the caller's (or subject's) account is hosted on the network's own PDS (branding key loginPdsUrl). Absent when the node has no such PDS configured." - }, - "nodeSession": { - "type": "boolean", - "description": "Whether this node can write to the caller's (or subject's) repo on its own: it holds credentials for the account (--account-credentials), so a session the node can't attribute (a sign-in against the PDS) is enough to go live here." } } } diff --git a/js/docs/src/content/docs/lex-reference/openapi.json b/js/docs/src/content/docs/lex-reference/openapi.json index e8ee278e6..b42cfe06d 100644 --- a/js/docs/src/content/docs/lex-reference/openapi.json +++ b/js/docs/src/content/docs/lex-reference/openapi.json @@ -4789,10 +4789,6 @@ "networkMember": { "type": "boolean", "description": "Whether the caller's (or subject's) account is hosted on the network's own PDS (branding key loginPdsUrl). Absent when the node has no such PDS configured." - }, - "nodeSession": { - "type": "boolean", - "description": "Whether this node can write to the caller's (or subject's) repo on its own: it holds credentials for the account (--account-credentials), so a session the node can't attribute (a sign-in against the PDS) is enough to go live here." } }, "required": ["roles", "policy", "space"] diff --git a/js/streamplace/src/agent.ts b/js/streamplace/src/agent.ts index e7803987e..b40faf814 100644 --- a/js/streamplace/src/agent.ts +++ b/js/streamplace/src/agent.ts @@ -1,18 +1,90 @@ import { Agent } from "@atproto/api"; import { Client } from "@atproto/lex"; +type SessionLike = { + did?: string; + fetchHandler: (url: string, init?: RequestInit) => Promise; +}; + +// The node's answer when the OAuth session a request carries is gone on its +// side: revoked, or not in its store any more. The client's own tokens can +// still look valid, so without noticing this the app stays "signed in" +// while every authenticated call fails. +const SESSION_REJECTED = /oauth session (revoked|not found)/i; +const REFRESH_GAVE_UP = + /TokenRefreshError|TokenRevoked|TokenInvalid|invalid_grant/i; + export class StreamplaceAgent extends Agent { // The @atproto/lex client for Streamplace (place.stream.* / games.*) XRPC and // records. It rides on the same session/fetchHandler as the @atproto/api Agent, // which we keep for OAuth and Bluesky (app.bsky.*) reads. client: Client; + /** Called once when the node rejects the session this agent holds: a 401 + * saying the OAuth session is revoked or unknown, or the OAuth client + * giving up on a refresh. The holder should drop the session and ask for + * a sign-in; until it does, every authenticated call fails. */ + onSessionRejected?: (reason: string) => void; + private rejected = false; + constructor(options: ConstructorParameters[0]) { - super(options); + const holder: { agent?: StreamplaceAgent } = {}; + super(StreamplaceAgent.guarded(options, holder)); + holder.agent = this; this.client = new Client({ did: this.did as `did:${string}:${string}` | undefined, fetchHandler: this.fetchHandler, }); } + + // Wraps a session's fetch handler so both the api Agent and the lex Client + // see the node's verdict on the session. A plain service URL is returned + // as is: an anonymous agent has no session to lose. + private static guarded( + options: ConstructorParameters[0], + holder: { agent?: StreamplaceAgent }, + ): ConstructorParameters[0] { + if ( + typeof options !== "object" || + options === null || + !("fetchHandler" in options) + ) { + return options; + } + const session = options as SessionLike; + const wrapped: SessionLike = { + get did() { + return session.did; + }, + fetchHandler: async (url, init) => { + let res: Response; + try { + res = await session.fetchHandler(url, init); + } catch (err: any) { + const text = `${err?.name ?? ""} ${err?.message ?? ""}`; + if (REFRESH_GAVE_UP.test(text)) holder.agent?.reject(text.trim()); + throw err; + } + if (res.status === 401) { + try { + const body = await res.clone().text(); + if (SESSION_REJECTED.test(body)) { + holder.agent?.reject(body.slice(0, 120)); + } + } catch { + // an unreadable body is not the answer we are looking for + } + } + return res; + }, + }; + return wrapped as ConstructorParameters[0]; + } + + private reject(reason: string) { + if (this.rejected) return; + this.rejected = true; + this.onSessionRejected?.(reason); + } } diff --git a/lexicons/place/stream/access/getStatus.json b/lexicons/place/stream/access/getStatus.json index 5ea901ec7..ff6c7d816 100644 --- a/lexicons/place/stream/access/getStatus.json +++ b/lexicons/place/stream/access/getStatus.json @@ -53,10 +53,6 @@ "networkMember": { "type": "boolean", "description": "Whether the caller's (or subject's) account is hosted on the network's own PDS (branding key loginPdsUrl). Absent when the node has no such PDS configured." - }, - "nodeSession": { - "type": "boolean", - "description": "Whether this node can write to the caller's (or subject's) repo on its own: it holds credentials for the account (--account-credentials), so a session the node can't attribute (a sign-in against the PDS) is enough to go live here." } } } diff --git a/pkg/config/config.go b/pkg/config/config.go index ec39db342..13fab0eaa 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -935,30 +935,6 @@ func (cli *CLI) NewCommand(name string) *urfavecli.Command { Destination: &cli.DisableIrohRelay, Sources: urfavecli.EnvVars("SP_DISABLE_IROH_RELAY"), }, - &urfavecli.StringFlag{ - Name: "account-credentials", - Usage: `JSON object of DID to app password for accounts the node may act for without an OAuth session, e.g. {"did:plc:abc":"xxxx-xxxx-xxxx-xxxx"}: the node logs in with the password (com.atproto.server.createSession) and uses that session to start, update and end the account's livestream records, publish its videos and act for its moderators. For a network whose OAuth is unavailable. Merged with --dev-account-creds.`, - Action: func(ctx context.Context, cmd *urfavecli.Command, s string) error { - if strings.TrimSpace(s) == "" { - return nil - } - var creds map[string]string - if err := json.Unmarshal([]byte(s), &creds); err != nil { - return fmt.Errorf("account-credentials must be a JSON object of DID to password: %w", err) - } - if cli.DevAccountCreds == nil { - cli.DevAccountCreds = map[string]string{} - } - for did, password := range creds { - if !strings.HasPrefix(did, "did:") { - return fmt.Errorf("account-credentials key %q is not a DID", did) - } - cli.DevAccountCreds[did] = password - } - return nil - }, - Sources: urfavecli.EnvVars("SP_ACCOUNT_CREDENTIALS"), - }, &urfavecli.StringFlag{ Name: "dev-account-creds", Usage: `(FOR DEVELOPMENT ONLY) did=password pairs for logging into test accounts without oauth (default: "")`, diff --git a/pkg/placestream/accessgetstatus.go b/pkg/placestream/accessgetstatus.go index da8832b9c..4c7297faf 100644 --- a/pkg/placestream/accessgetstatus.go +++ b/pkg/placestream/accessgetstatus.go @@ -21,10 +21,8 @@ type AccessGetStatus_Output struct { // did: The authenticated caller, when there is one. Did *string `json:"did,omitempty"` // networkMember: Whether the caller's (or subject's) account is hosted on the network's own PDS (branding key loginPdsUrl). Absent when the node has no such PDS configured. - NetworkMember *bool `json:"networkMember,omitempty"` - // nodeSession: Whether this node can write to the caller's (or subject's) repo on its own: it holds credentials for the account (--account-credentials), so a session the node can't attribute (a sign-in against the PDS) is enough to go live here. - NodeSession *bool `json:"nodeSession,omitempty"` - Policy AccessDefs_PolicyView `json:"policy"` + NetworkMember *bool `json:"networkMember,omitempty"` + Policy AccessDefs_PolicyView `json:"policy"` // roles: Every role the caller effectively holds. Roles []string `json:"roles"` // space: The node's access-control space: at://{authority}/space/place.stream.access.control/self (A space URI; not validated as a classic at-uri because the space form is newer than that grammar.) diff --git a/pkg/spxrpc/place_stream_access.go b/pkg/spxrpc/place_stream_access.go index 0181f2b51..10fc3a5fa 100644 --- a/pkg/spxrpc/place_stream_access.go +++ b/pkg/spxrpc/place_stream_access.go @@ -155,19 +155,6 @@ func (s *Server) handlePlaceStreamAccessGetStatus(ctx context.Context, subject s out.Roles = append(out.Roles, role) } } - // An account the node holds credentials for (--account-credentials) - // needs no OAuth session here: the node writes its records itself, so a - // client signed in against the PDS alone may go live. Answered for the - // caller, or for the DID an unattributable client names. - if who := did; who != "" || strings.HasPrefix(subject, "did:") { - if who == "" { - who = subject - } - if s.cli != nil && s.cli.DevAccountCreds[who] != "" { - yes := true - out.NodeSession = &yes - } - } // The chat lock and the caller's standing under it, so the composer can // explain itself rather than post into the void. if s.ATSync != nil { diff --git a/pkg/statedb/user_client.go b/pkg/statedb/user_client.go index ee77bc5e3..64783dfc9 100644 --- a/pkg/statedb/user_client.go +++ b/pkg/statedb/user_client.go @@ -33,13 +33,10 @@ type passwordSession struct { } // UserXrpcClient is the node's client for writing to a user's repo. It comes -// from the user's stored OAuth session, or, for an account the operator -// gave the node credentials for (--account-credentials / --dev-account-creds), -// from a session created with that app password, cached for an hour and -// made again on demand. The credential path is for a network whose OAuth is -// broken or absent: the streamer never signs in here, yet the node still -// starts, updates and ends their livestream records, publishes their VODs -// and acts for their moderators. +// from the user's stored OAuth session, or, for a test account the node was +// given a password for (--dev-account-creds, development only), from a +// session created with that password, cached for an hour and made again on +// demand. func (state *StatefulDB) UserXrpcClient(ctx context.Context, did string) (UserClient, error) { if c, err, ok := state.passwordClient(ctx, did); ok { return c, err @@ -65,8 +62,8 @@ func (state *StatefulDB) UserXrpcClient(ctx context.Context, did string) (UserCl return client, nil } -// HasUserSession reports whether UserXrpcClient can act for did: the node -// holds credentials for the account, or a stored OAuth session. +// HasUserSession reports whether UserXrpcClient can act for did: a stored +// OAuth session, or (development) a password for the account. func (state *StatefulDB) HasUserSession(did string) bool { if state.CLI != nil && state.CLI.DevAccountCreds[did] != "" { return true