Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,48 @@ to it yet.
sessions. [deploy/keys.md](deploy/keys.md) has the shape and how to turn
it on.

- **Assistants sign in with OAuth, and the owner approves at home.** The
authorization server follows the MCP authorization spec, revision
2026-07-28: OAuth 2.1 with PKCE S256 only, metadata at
`/.well-known/oauth-authorization-server` with `iss` in every
authorization response (RFC 9207), protected resource metadata for
`https://cloud.ftw.energy/mcp` (RFC 9728), and tokens bound to that
resource (RFC 8707). A client names itself by a client ID metadata
document, fetched behind a fence (https on 443 to a public address checked
as the socket connects, no redirects, no proxy, 16 KiB, 5 s, cached), or
registers at `/oauth/register` (RFC 7591), kept for ChatGPT and older
clients. There is no account and no password: `/oauth/authorize` asks
`ftwkeysd` for a new agent key and has it sign the grant offer
(srcfl/ftw `docs/agents.md`), sent as `{o, kid, sig}`: the key, the
client, the host it is known by and whether FTW Cloud knows it, the
scopes, ops and caps asked for, the page's code, and an end ten minutes
out, with no home, since the owner picks it. The page shows the client's name, where the browser goes back, the
rights, the code, a QR and `https://app.ftw.energy/a#<request>.<offer>`;
the app checks the offer against the offer key it pins by kid, which
`/.well-known/ftw-offer-key` serves. The box's page or an owner who types
the eight-character code finds the offer once at `/oauth/requests?code=`.
A client FTW Cloud knows by the exact URL of its document is shown as
verified, and an error goes back to it at once; any other gets a page
with a link back. Each endpoint a stranger can call is limited per
address and for everyone, before anything is fetched. The owner
approves in the app or on the box's page; the box lists the agent's key,
for that client, in the roster it signs (an agent row, with no tunnel of
its own), and when that roster arrives the
page, waiting on a server-sent event, returns to the client with a code.
Only the browser that started the request gets it. Access tokens last 10
minutes; refresh tokens rotate, a retry within 5 s works once more, and a
later reuse revokes the family, as does a code used twice, even after its
consent is gone. A token carries its home sealed with a fresh nonce under
the route key (`oauth-route.key`, shared by every instance that routes),
so any instance can route it and no client sees an id that links its
tokens; the service keeps hashes, never tokens. A token allows the scopes it
carries that the newest roster still grants, so an owner who narrows a
grant narrows it at once, and it stops at the agent's end by the
service's own clock. A roster without the agent ends
its tokens and its key, and so does a second home listing the same key,
or the box purging its home; a key the keys part could not forget stays
queued until it has. Only each home's newest roster is applied.

Not built yet:

| Step | What | Where |
Expand Down
17 changes: 17 additions & 0 deletions cmd/ftwcloudd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import (

"github.com/srcfl/ftw-cloud/internal/apns"
"github.com/srcfl/ftw-cloud/internal/appsession"
"github.com/srcfl/ftw-cloud/internal/keys"
"github.com/srcfl/ftw-cloud/internal/oauth"
"github.com/srcfl/ftw-cloud/internal/push"
"github.com/srcfl/ftw-cloud/internal/registry"
"github.com/srcfl/ftw-cloud/internal/server"
Expand Down Expand Up @@ -73,6 +75,7 @@ func serve(args []string) error {
apnsWatch := fs.String("apns-watch-topic", "energy.ftw.app.watchkitapp", "the watch app's bundle id")
devPush := fs.String("dev-push-listen", "", "loopback address for registering a token by hand (development builds only)")
devCapture := fs.String("dev-apns-capture", "", "write each alert to this directory instead of sending it to Apple (development builds only)")
keysSocket := fs.String("keys-socket", "/run/ftw-cloud-keys/keys.sock", "ftwkeysd's socket; assistants can connect only while it answers")
_ = fs.Parse(args)

harden()
Expand Down Expand Up @@ -110,6 +113,16 @@ func serve(args []string) error {
tokens := token.New(st, reg, log)
srv := &server.Server{Store: st, Registry: reg, Uplink: up, Tokens: tokens, Log: log}

// Assistants: the authorization server, with agent keys in ftwkeysd.
agentKeys := keys.Dial(*keysSocket)
defer agentKeys.Close()
auth, err := oauth.New(filepath.Join(*stateDir, "oauth.db"), agentKeys, log)
if err != nil {
return err
}
defer auth.Close()
srv.OAuth = auth

// Alerts: the box's signed events to its phones through Apple, and
// box.unreachable when a box is away. Widgets: a push with no data when
// prices or a car change. Without a key the service keeps registrations
Expand All @@ -133,8 +146,11 @@ func serve(args []string) error {
up.OnRoster = func(home string, r *cloudwire.Roster) {
pushSvc.Roster(home, r)
tokens.Roster(home, r)
auth.Roster(home, r)
}
up.OnGone = pushSvc.BoxGone
// A purged home's agents, their tokens and keys, and its consents go.
up.OnPurge = auth.DropHome

// Phones register their tokens over their app session, so without a
// Noise key only the development route registers one.
Expand Down Expand Up @@ -188,6 +204,7 @@ func serve(args []string) error {
defer stop()
go srv.Upkeep(ctx, time.Minute)
go pushSvc.Run(ctx)
go auth.Run(ctx)
if err := pushSvc.Resume(); err != nil {
log.Warn("could not restart the dead man's switch", "err", err)
}
Expand Down
1 change: 1 addition & 0 deletions deploy/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ services:
- -service-key=/run/secrets/ftw-cloud-service.key
- -acme-email=${FTW_CLOUD_ACME_EMAIL:-}
- -noise-key=/run/secrets/noise/noise.key
- -keys-socket=/run/ftw-cloud-keys/keys.sock
environment:
# /source publishes it, so anyone can compare it with what CI built.
FTW_CLOUD_IMAGE: ${FTW_CLOUD_IMAGE}
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,5 @@ require (
modernc.org/libc v1.75.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
rsc.io/qr v0.2.0 // indirect
)
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,5 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
Loading
Loading