diff --git a/.env.example b/.env.example index 2f0c65a4..1b8bf019 100644 --- a/.env.example +++ b/.env.example @@ -23,3 +23,64 @@ # SINGLE_USER_AUTO_LOGIN=true # ENABLE_PPROF=false + +# ---------------------------------------------------------------------------- +# Third-party OAuth2 connections (Strava, etc.) +# ---------------------------------------------------------------------------- +# Apps like the Strava community app can pull user-specific data (recent +# activity, etc.) once the user has connected their account. Tronbyt itself +# brokers the OAuth dance; you (the server admin) provide the client +# credentials per provider. +# +# Setup, per provider: +# 1. Register an OAuth application with the provider. +# 2. Set its redirect URI to https:///oauth-callback +# (must match exactly — providers reject mismatches). +# 3. Copy the client ID/secret here, redeploy. +# 4. Users will see a "Connect " button on each app's config +# page when the schema declares an OAuth2 field for that provider. +# +# Users manage their connections at /connections, or from the "Connect" +# button on an app's config page. +# +# Strava — https://www.strava.com/settings/api +# Set "Authorization Callback Domain" to the bare host you browse your +# server with (e.g. tronbyt.local or 192.168.1.155) — no scheme, no port. +# localhost and 127.0.0.1 are always accepted. New Strava apps start in +# "Single Player Mode" (1 connected athlete); the dashboard has a +# self-service upgrade to 10. +# STRAVA_CLIENT_ID= +# STRAVA_CLIENT_SECRET= +# +# GitHub — https://github.com/settings/developers (OAuth Apps) +# Uses the DEVICE FLOW: no redirect URI to register and no client secret +# needed — the client ID alone enables it. You must tick "Enable Device +# Flow" on the OAuth app, or the connection fails with device_flow_disabled. +# Users get a short code shown both in the browser and on the display, and +# approve it at github.com/login/device. +# GITHUB_CLIENT_ID= +# GITHUB_CLIENT_SECRET= # optional; only needed for the redirect flow +# +# Spotify — https://developer.spotify.com/dashboard +# Redirect URI must be HTTPS, except literal loopback addresses: +# http://127.0.0.1:8000/oauth-callback works, but "localhost", LAN IPs, +# and .local names over plain http are rejected. Development Mode apps +# allow up to 5 allowlisted users and require the app owner to have +# Spotify Premium. +# SPOTIFY_CLIENT_ID= +# SPOTIFY_CLIENT_SECRET= +# +# Google — https://console.cloud.google.com/apis/credentials +# Create an "OAuth client ID" of type "Web application" and add +# https:///oauth-callback as an authorized redirect URI. +# Enable the APIs your apps use (e.g. the Google Analytics Data API for +# GA4 apps) under "APIs & Services". +# IMPORTANT: while the project's OAuth consent screen is in "Testing" +# status, refresh tokens expire after 7 days and every user must be +# reconnected weekly. Publish the consent screen to "In production" +# (Audience page) for durable tokens — you do NOT need Google's +# verification: users just click through an "unverified app" warning, +# and the only cost is a 100-user lifetime cap, irrelevant for +# self-hosting. +# GOOGLE_CLIENT_ID= +# GOOGLE_CLIENT_SECRET= diff --git a/cmd/server/serve/cmd.go b/cmd/server/serve/cmd.go index bd589edc..d867763b 100644 --- a/cmd/server/serve/cmd.go +++ b/cmd/server/serve/cmd.go @@ -53,7 +53,7 @@ func run(cmd *cobra.Command, args []string) error { sanitizeDB(cmd.Context(), db) // AutoMigrate (ensure schema exists) - if err := db.AutoMigrate(&data.User{}, &data.Device{}, &data.App{}, &data.WebAuthnCredential{}, &data.Setting{}, &data.OIDCIdentity{}); err != nil { + if err := db.AutoMigrate(&data.User{}, &data.Device{}, &data.App{}, &data.WebAuthnCredential{}, &data.Setting{}, &data.OIDCIdentity{}, &data.Connection{}); err != nil { return fmt.Errorf("failed to migrate schema: %w", err) } diff --git a/docs/oauth-connections.md b/docs/oauth-connections.md new file mode 100644 index 00000000..090eee91 --- /dev/null +++ b/docs/oauth-connections.md @@ -0,0 +1,341 @@ +# Third-party OAuth2 connections + +This document describes the per-user OAuth2 connection store added in this +PR, why it is shaped the way it is, and how it relates to the closed +issue [#184 "Implement OAuth callback URL"][issue-184]. + +## Goal + +Let users connect a third-party account (Strava is the first concrete +provider) once, and have any pixlet app that needs that account render +with a fresh access token automatically. No more pasting refresh tokens +into the app config form. + +The motivating example is Strava: today, the [`tronbyt/apps` +strava.star][tronbyt-strava] requires a user to register their own Strava +OAuth app, manually run an authorize URL, scrape `?code=` from the +resulting error page, and `curl` the token endpoint to exchange code for +refresh token, then paste three secrets into the app config form. After +this PR, the user clicks **Connect Strava** and authorizes in the +browser — the rest happens server-side. + +## Why #184 was closed, and how this avoids the blocker + +Issue #184 was closed `not_planned`. The maintainer concern was: + +> Without a centralized system we couldn't set up, eg, a Google Calendar +> integration that you can OAuth to, because everyone would still have a +> separate callback URL. + +That blocker is real for the Tidbyt-cloud model, where the *app +developer* bakes their `client_id` + encrypted `client_secret` into the +starlark, and Tidbyt-the-company runs `appauth.tidbyt.com` as a shared +redirect URI. A self-hosted Tronbyt instance has neither the secret- +decryption key nor the registered redirect URI, so that approach can't +work without Tronbyt-the-org running infrastructure. + +This PR sidesteps the blocker by changing whose credentials are used: + +- The **server admin** registers their own OAuth application with each + provider (5-minute one-time setup at e.g. + `https://www.strava.com/settings/api`). +- They set `STRAVA_CLIENT_ID` / `STRAVA_CLIENT_SECRET` (and the equivalent + for any future provider) as env vars on the server. +- They register their callback URL — `https:///oauth-callback` + — with the provider. That URL is unique to their instance, so it is + always valid for them. +- Every user on that instance shares the admin's OAuth app, exactly the + way they share the server itself. + +This means: +- **No Tronbyt-org infrastructure** required. +- **No pixlet changes** required (we honour the existing `schema.OAuth2` + field shape). +- **No app-developer secret-decryption key** required. +- **Self-hoster cost**: ~5 minutes per provider, once. Zero per user. + +## Architecture + +### Data model + +```go +type Connection struct { + ID uint + UserID string // FK to User.Username + Provider string // "strava", "spotify", ... + ExternalID string // provider-side user id (e.g. Strava athlete.id) + DisplayName string // shown as "Connected as " in UI + Scopes string // space-separated, granted scopes + AccessToken []byte // AES-256-GCM encrypted + RefreshToken []byte // AES-256-GCM encrypted + AccessExpiresAt time.Time + CreatedAt, UpdatedAt time.Time +} +// uniqueIndex(UserID, Provider): one connection per (user, provider). +``` + +Per-user, not per-app-instance. A user installing the Strava app on two +devices does not connect twice — both apps see the same connection. + +### Encryption at rest + +The session `secret_key` (already used to sign session cookies) is +HKDF-equivalent-derived (SHA-256 of a fixed info string + the key) into +a 32-byte AES-GCM key. Tokens are sealed as `nonce || ciphertext`. +Rotating the session secret invalidates all stored tokens, which is the +right behavior — a leaked session secret is a leaked token store. + +### OAuth flow + +``` + user Tronbyt server provider + │ │ │ + │ click "Connect Strava" │ │ + ├─────────────────────────►│ │ + │ │ store {state, return_to, │ + │ │ provider} in session │ + │ 303 to provider │ │ + │◄─────────────────────────┤ │ + │ authorize w/ scopes │ │ + ├──────────────────────────┴─────────────────────────►│ + │ 303 to /oauth-callback?code=…&state=… │ + │◄────────────────────────────────────────────────────┤ + │ GET /oauth-callback │ │ + ├─────────────────────────►│ │ + │ │ verify state, exchange │ + │ │ code for tokens │ + │ ├─────────────────────────►│ + │ │ tokens │ + │ │◄─────────────────────────┤ + │ │ identify athlete, persist│ + │ │ encrypted Connection row │ + │ 303 to return_to │ │ + │◄─────────────────────────┤ │ +``` + +A single stable callback URL, `/oauth-callback`, handles every provider — +the in-flight provider name lives in the session, not the URL path. This +matches what each provider expects to be pre-registered. + +### Render-time token injection + +`RenderApp` calls `injectConnectionTokens` before invoking pixlet. The +helper: + +1. Loads the app's oauth2 fields via `oauth2FieldsForApp` (cached — see + below). +2. Maps each field's `authorization_endpoint` to a known provider via the + registry. +3. Looks up the user's `Connection` for that provider. +4. Mints a fresh access token (refreshing if expired or about to be). +5. Sets `config[field.id] = ""` — a plain string, since + pixlet stringifies config values before they reach starlark. + +Apps see a string bearer token; refresh tokens never leave the server. +Provider-side identifiers like Strava's athlete id are derivable from +the token itself. + +This is deliberately *not* the Tidbyt contract, where the app's own +starlark `handler` exchanged a code and stored a refresh token. On a +self-hosted server an app cannot refresh anything: the refresh grant +needs the admin's `client_secret`, which must never reach starlark, and +`secret.decrypt` returns `None` without Tidbyt's key. Roughly a third of +the community `schema.OAuth2` apps already read `config[id]` as a bearer +token and work unmodified; the rest need a one-line app-side change. + +#### Schema extraction is cached + +Extracting oauth2 fields requires `renderer.GetSchema`, which loads and +executes the whole applet — the same work the render itself does, so a +naive call doubles per-render Starlark cost for *every* app, including +apps with no oauth2 fields. `oauth2FieldsForApp` caches the extracted +fields per `(path, mtime, size)` and revalidates with a single +`os.Stat`; negative results are cached too, so the common case is +essentially free. Measured on an M-series Mac: 2.8 ms → 0.9 µs for +`strava.star` (46 KB), 27.7 ms → 1.0 µs for `coingecko_price.star` +(870 KB). A Pi Zero 2 W is roughly 15–25× slower, so this is tens to +hundreds of milliseconds of CPU saved per render. App uploads rewrite +the `.star` file, so mtime/size invalidate the entry naturally; +directory-form apps skip the cache. + +#### Schema handlers + +`handleSchemaHandler` (which backs `schema.Generated` and +`schema.Typeahead`) injects tokens too. The canonical OAuth pattern is +`schema.Generated(source = )`, whose handler expects +the token as its parameter — but the browser sends no value for an +oauth2 field, so the server substitutes the injected token when the +request's `source` names a field it just injected. Gating on the +injected set means a client-supplied `source` can only ever select a +token that same user could already read via config. + +### Token freshness and refresh + +`AccessTokenForUser` returns the stored access token unless it is +missing, expired, or within 60 seconds of expiring. A **zero expiry +means "never expires"**, following `x/oauth2`'s own convention — +providers that omit `expires_in` usually issue no refresh token either, +so treating zero as expired would refresh (and fail) on every render +forever. + +Refreshes are **single-flighted per connection**. Strava and other +rotating-refresh-token providers invalidate the old refresh token the +moment a new one is issued, so two concurrent renders racing to refresh +can persist a dead token and brick the connection until the user +re-authorizes. The loser of the race re-reads the row under the lock and +returns the winner's fresh token. + +### Providers + +| Provider | Flow | Notes | +| --- | --- | --- | +| Strava | redirect (params) | Scopes must be **comma**-joined (`ScopeJoin`), not space-joined. Rotates refresh tokens. Athlete summary comes back in the token response. New apps are capped at 1 connected athlete ("Single Player Mode"), self-service upgrade to 10. The app's "Authorization Callback Domain" is the bare host, no scheme or port; `localhost`/`127.0.0.1` are always accepted. | +| Spotify | redirect (Basic header) | May omit `refresh_token` on refresh (keep the stored one). Redirect URIs must be HTTPS *except* literal loopback addresses — `http://127.0.0.1:8000/oauth-callback` works, but `localhost`, LAN IPs, and `.local` names over plain http are rejected. Development Mode allows 5 allowlisted users and requires the app owner to have Premium. | +| GitHub | **device** | Public client: client ID alone enables it, no secret and no redirect URI. Requires "Enable Device Flow" on the OAuth app (off by default; otherwise the flow fails with `device_flow_disabled`). Newly registered OAuth apps have **"Expire user access tokens" on by default** — 8-hour tokens plus a 6-month refresh token — so the refresh path matters here, and refreshing a device-flow token needs no secret either (see `refreshCreds`). GitHub answers a pending poll with HTTP **200** and an RFC error body, which the oauth2 library handles correctly. | +| Google | redirect (params) | Redirect flow only: Google's device-flow scope allowlist (TV-class scopes) excludes API scopes like `analytics.readonly`. A refresh token is only issued with `access_type=offline`, and only guaranteed on re-auth with `prompt=consent` — pinned explicitly via `AuthCodeParams` on the `Provider` rather than inherited from the default options, whose spelling tracks `oauth2.ApprovalForce` (it has changed once already, from `approval_prompt=force`, which Google now rejects when `prompt` is present). Refresh responses don't rotate the refresh token. **Consent-screen status matters**: a project in "Testing" issues refresh tokens that expire after 7 days; publishing to "In production" (verification not required — users click through a warning) makes them durable, at the cost of a 100-user lifetime cap that self-hosters will never hit. `analytics.readonly` is a "sensitive" (not restricted) scope, so no security assessment is needed. | + +The redirect-flow providers are confidential clients: the token exchange +needs the admin's client secret, which is why the server brokers it. + +## Device authorization grant (RFC 8628) + +The redirect flow's weak spot on self-hosted hardware is the redirect URI +— every instance has a different address, and providers increasingly +refuse plain-http LAN hosts. The device grant sidesteps it entirely: +there is no redirect URI. The server asks the provider for a short code, +the user approves it on a phone, and the server polls for the token. + +That fits a display particularly well, so the code is rendered **onto the +matrix itself** (`device_code_image.go`) as well as shown in the browser: +read it off the shelf, approve on your phone, done. The frame is pushed +under a fixed `__device_code.webp` ephemeral name — `GetNextAppImage` +serves ephemeral frames once and deletes them, and the fixed name means a +re-push replaces the pending code rather than queueing a backlog. The +frame is cleared when the flow ends. Font size steps down automatically +so a long code is never clipped. + +Flows live in memory (`Server.deviceFlows`), keyed by a random id and +owned by the user who started them; the waiting page polls +`/connections/device/status/{id}`. Polling runs in a goroutine with a +deadline, so closing the browser tab doesn't abandon the connection. + +### Library gotchas (golang.org/x/oauth2 v0.36.0) + +Verified against the library source, since each one bites silently: + +- `DeviceAccessToken` **blocks** until approval, denial, or expiry — + always call it from a goroutine. +- **Always set `AuthStyle` explicitly.** With the zero value + (auto-detect) and an empty secret, the library sends Basic auth with an + empty password, and since it only caches the style on *success* — while + every pending poll is an error — it probes both styles on every tick, + doubling the request rate. `deviceConfig` forces `AuthStyleInParams`. +- If a provider omits `expires_in`, **no deadline is installed** and the + loop polls forever; `pollDeviceFlow` supplies its own. +- The expiry error is often a `*url.Error` wrapping + `context.DeadlineExceeded`, so compare with `errors.Is`, never `==`. +- Terminal errors arrive as `*oauth2.RetrieveError`; switch on + `ErrorCode`. Microsoft spells denial `authorization_declined` rather + than the RFC's `access_denied`. +- A provider that signals "pending" with a **bare HTTP status and no RFC + error body** kills the loop after one poll. + +### Adding Trakt (needs a hand-rolled poller) + +Trakt is the obvious next device-flow provider for a media display, but +it cannot use `DeviceAccessToken` at all. Three independent +incompatibilities, verified against the live API: + +1. Polling returns **bare status codes with a zero-length body** — 400 = + pending, 429 = slow down, 410 = expired, 418 = denied, 404 = invalid, + 409 = already used. With no `error` field, the library's loop exits on + the first poll. +2. The token request field is named **`code`**, not `device_code`, and + Trakt sends no `grant_type` — a request-shape mismatch no response + shim can fix. +3. Credentials must go in the body, so `AuthStyle` must be explicit. + +So Trakt wants a small dedicated poller (~40 lines) rather than a +transport shim. Two further gotchas: the OAuth host is now +`https://auth.trakt.tv` (`api.trakt.tv` still works but is legacy), and +access tokens dropped to **24 hours** in March 2025 with single-use +refresh tokens — so it leans hard on the single-flighted refresh path. + +### UI + +`/connections` lists every provider the admin has configured along with +the user's connection state, with Connect/Disconnect controls. The nav +link appears only when at least one provider has credentials. + +`web/templates/manager/configapp.html` gains a `case "oauth2"` in its +field renderer that produces one of three states based on the +server-annotated schema: + +- **Provider unknown** — show a maintenance note ("add provider support + in `internal/connections`"). +- **Configured but not connected** — render a `Connect ` link to + `/connections/start/?return_to=…&scopes=…`. +- **Configured and connected** — show "Connected as " and + a `Disconnect` POST form. + +The original schema fields stay intact (apps still see their declared +`schema.OAuth2`); the annotation adds `tronbyt_*` keys the JS keys off. + +## Adding a new provider + +1. Add a `Provider{...}` constructor in `internal/connections/.go` + with the auth/token URLs, `AuthStyle`, any `ScopeJoin` quirk, and an + optional `Identify` callback. +2. Register it in `NewServer` (where `Strava()` and `Spotify()` are). +3. Add `XYZ_CLIENT_ID` / `XYZ_CLIENT_SECRET` to `Settings` and route them + through `(*Settings).ConnectionClientCreds`. +4. Update `.env.example`, including any provider-specific redirect-URI or + user-cap rules the admin needs to know before registering their app. + +A future provider that needs custom token-endpoint behaviour (e.g. a +non-standard refresh shape) can extend `Provider` with a +`RefreshFn`-style hook. None of the current providers need that. + +### Not every integration should be OAuth + +Several of the best ambient-display sources need no OAuth at all — +Goodreads shelf RSS, Last.fm, Todoist personal tokens, Open Library — and +some OAuth providers carry sharp edges: Google refresh tokens expire +after 7 days while a Cloud project sits in "Testing" status (supported +now, but admins must publish their consent screen to production — see +the provider table), and Fitbit's API is being retired into Google's +restricted-scope regime. +Providers offering the **device authorization grant** (GitHub, Trakt, +Microsoft, YouTube) are a better fit for a device on a shelf: no redirect +URI at all, and the code can be shown on the matrix itself. That would +slot in as a second grant type alongside the authorization-code flow. + +## Migration + +Pure additive: a single new `Connection` table created via GORM +`AutoMigrate`. No backfill, no data migration. The existing +`tronbyt/apps` Strava app continues to work with its three-secret-paste +flow until the companion PR over there switches it back to +`schema.OAuth2`. + +## Threat model notes + +- **Open-redirect**: `return_to` is rejected unless it starts with `/`, + parses as a path-only URL, and contains no backslash. The backslash + check matters: `url.Parse` treats `\` as an ordinary path byte, but + browsers normalize `/\evil.com` into the protocol-relative + `//evil.com`, which would otherwise slip past the `//` guard and leak + the flow's `state` to the attacker origin via `Referer`. +- **CSRF on the OAuth flow**: a 32-byte random `state` is verified on + callback against the session. +- **CSRF on disconnect**: relies on SameSite=Lax cookies (matches the + rest of the app). +- **Token leakage**: refresh + access tokens encrypted at rest; never + surfaced to apps; `json:"-"` on the model so they don't appear in any + serialization. +- **Provider impersonation**: the callback handler never decodes user + input as authoritative — the provider name comes from the session + pending state, not the URL. + +[issue-184]: https://github.com/tronbyt/server/issues/184 +[tronbyt-strava]: https://github.com/tronbyt/apps/tree/main/apps/strava diff --git a/internal/config/config.go b/internal/config/config.go index c3b2804a..bca13b59 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -44,6 +44,36 @@ type Settings struct { OIDCAdminGroupClaim string `env:"OIDC_ADMIN_GROUP_CLAIM" envDefault:"groups"` OIDCAdminGroupValue string `env:"OIDC_ADMIN_GROUP_VALUE"` OIDCUsernameClaim string `env:"OIDC_USERNAME_CLAIM" envDefault:"preferred_username"` + + // Third-party OAuth2 connections (per-provider client credentials). + // Empty values disable that provider's "Connect" button. + StravaClientID string `env:"STRAVA_CLIENT_ID"` + StravaClientSecret string `env:"STRAVA_CLIENT_SECRET"` + SpotifyClientID string `env:"SPOTIFY_CLIENT_ID"` + SpotifyClientSecret string `env:"SPOTIFY_CLIENT_SECRET"` + // GitHub uses the device flow, which needs no secret — a client id + // alone is enough to enable it. + GitHubClientID string `env:"GITHUB_CLIENT_ID"` + GitHubClientSecret string `env:"GITHUB_CLIENT_SECRET"` + GoogleClientID string `env:"GOOGLE_CLIENT_ID"` + GoogleClientSecret string `env:"GOOGLE_CLIENT_SECRET"` +} + +// ConnectionClientCreds returns the (client_id, client_secret) pair for a +// provider name, or empty strings if not configured. +func (s *Settings) ConnectionClientCreds(provider string) (string, string) { + switch provider { + case "strava": + return s.StravaClientID, s.StravaClientSecret + case "spotify": + return s.SpotifyClientID, s.SpotifyClientSecret + case "github": + return s.GitHubClientID, s.GitHubClientSecret + case "google": + return s.GoogleClientID, s.GoogleClientSecret + default: + return "", "" + } } func (s *Settings) SystemAppsDir() string { diff --git a/internal/connections/crypto.go b/internal/connections/crypto.go new file mode 100644 index 00000000..1349e020 --- /dev/null +++ b/internal/connections/crypto.go @@ -0,0 +1,62 @@ +// Package connections implements the third-party OAuth2 connection store +// that lets pixlet apps (Strava, Spotify, Google Calendar, ...) render with +// a fresh access token without users hand-pasting refresh tokens. +package connections + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "errors" + "io" +) + +// deriveKey turns the server's session secret_key (any length) into a stable +// 32-byte AES-256 key. We use a fixed info string so rotating session keys +// (which would also rotate this key) intentionally invalidates stored tokens. +func deriveKey(secret string) [32]byte { + return sha256.Sum256([]byte("tronbyt-connections-v1\x00" + secret)) +} + +// Seal encrypts plaintext with AES-256-GCM. Output layout: nonce || ciphertext. +func Seal(secret string, plaintext []byte) ([]byte, error) { + if len(plaintext) == 0 { + return nil, nil + } + key := deriveKey(secret) + block, err := aes.NewCipher(key[:]) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, err + } + return gcm.Seal(nonce, nonce, plaintext, nil), nil +} + +// Open reverses Seal. Empty input returns empty output (treated as "not set"). +func Open(secret string, sealed []byte) ([]byte, error) { + if len(sealed) == 0 { + return nil, nil + } + key := deriveKey(secret) + block, err := aes.NewCipher(key[:]) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + if len(sealed) < gcm.NonceSize() { + return nil, errors.New("connections: sealed token too short") + } + nonce, ct := sealed[:gcm.NonceSize()], sealed[gcm.NonceSize():] + return gcm.Open(nil, nonce, ct, nil) +} diff --git a/internal/connections/crypto_test.go b/internal/connections/crypto_test.go new file mode 100644 index 00000000..bbe766a3 --- /dev/null +++ b/internal/connections/crypto_test.go @@ -0,0 +1,62 @@ +package connections + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSealOpenRoundtrip(t *testing.T) { + secret := "this-is-a-test-secret" + plaintext := []byte("strava-refresh-token-1234567890") + + sealed, err := Seal(secret, plaintext) + require.NoError(t, err) + require.NotEmpty(t, sealed) + assert.False(t, bytes.Equal(sealed, plaintext), "sealed output should differ from plaintext") + + opened, err := Open(secret, sealed) + require.NoError(t, err) + assert.Equal(t, plaintext, opened) +} + +func TestSealNonDeterministic(t *testing.T) { + // AES-GCM uses a random nonce, so sealing the same plaintext twice + // must produce different ciphertexts. This guards against ECB-style + // regressions if anyone "simplifies" the implementation later. + secret := "secret" + plaintext := []byte("same-plaintext-each-time") + + a, err := Seal(secret, plaintext) + require.NoError(t, err) + b, err := Seal(secret, plaintext) + require.NoError(t, err) + assert.False(t, bytes.Equal(a, b), "two seals of the same plaintext must differ") +} + +func TestOpenWithWrongKey(t *testing.T) { + sealed, err := Seal("right-secret", []byte("payload")) + require.NoError(t, err) + + _, err = Open("wrong-secret", sealed) + assert.Error(t, err, "opening with the wrong key must fail") +} + +func TestSealOpenEmpty(t *testing.T) { + // Empty input is a legitimate "not set" state — round-trip without + // error so the service can store/clear tokens uniformly. + sealed, err := Seal("secret", nil) + require.NoError(t, err) + assert.Empty(t, sealed) + + opened, err := Open("secret", nil) + require.NoError(t, err) + assert.Empty(t, opened) +} + +func TestOpenTooShort(t *testing.T) { + _, err := Open("secret", []byte{1, 2, 3}) + assert.Error(t, err) +} diff --git a/internal/connections/github.go b/internal/connections/github.go new file mode 100644 index 00000000..3228d138 --- /dev/null +++ b/internal/connections/github.go @@ -0,0 +1,75 @@ +package connections + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" + "time" + + "golang.org/x/oauth2" +) + +// GitHub returns the GitHub provider. GitHub is the marquee device-flow +// provider: the token exchange needs only a client id, so an admin can +// enable it with a single env var and no secret. +// +// Note for admins: "Enable Device Flow" must be ticked on the OAuth app +// (Settings → Developer settings → OAuth Apps); without it the flow +// fails with device_flow_disabled. +func GitHub() *Provider { + return &Provider{ + Name: "github", + DisplayName: "GitHub", + AuthorizeURL: "https://github.com/login/oauth/authorize", + TokenURL: "https://github.com/login/oauth/access_token", + DeviceAuthURL: "https://github.com/login/device/code", + DeviceAuthNeedsSecret: false, + AuthStyle: oauth2.AuthStyleInParams, + DefaultScopes: []string{"read:user"}, + Identify: identifyGitHub, + } +} + +// identifyGitHub calls /user for the account id and a display name. +func identifyGitHub(ctx context.Context, accessToken string) (externalID, displayName string, err error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/user", nil) + if err != nil { + return "", "", err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", "", err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return "", "", fmt.Errorf("github /user returned %d", resp.StatusCode) + } + + var body struct { + ID int64 `json:"id"` + Login string `json:"login"` + Name string `json:"name"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return "", "", err + } + + externalID = strconv.FormatInt(body.ID, 10) + switch { + case body.Login != "": + displayName = body.Login + case body.Name != "": + displayName = body.Name + default: + displayName = formatExternalID("github", externalID) + } + return externalID, displayName, nil +} diff --git a/internal/connections/google.go b/internal/connections/google.go new file mode 100644 index 00000000..106f2975 --- /dev/null +++ b/internal/connections/google.go @@ -0,0 +1,48 @@ +package connections + +import ( + "golang.org/x/oauth2" +) + +// Google returns the Google OAuth2 provider definition. Endpoints from +// https://developers.google.com/identity/protocols/oauth2/web-server. +// Redirect (authorization code) flow only: Google's device flow has a +// small scope allowlist (TV-class scopes like YouTube and Drive) that +// excludes the API scopes apps actually declare — analytics.readonly, +// calendar.readonly, and friends — so DeviceAuthURL stays empty. +// +// Google only issues a refresh token when the authorize redirect carries +// access_type=offline, and only guarantees one on re-auth when +// prompt=consent forces the consent screen. Those two are pinned in +// AuthCodeParams rather than inherited from the default options: the +// default happens to spell the same thing today, but it goes through +// oauth2.ApprovalForce, whose spelling has changed before (it used to be +// approval_prompt=force — a parameter Google now rejects when prompt is +// present). Refresh responses do not rotate the refresh token — the +// omitted-refresh-token path in Service.refresh keeps the stored one. +// +// No Identify: the userinfo endpoint needs an email/profile scope on the +// token, and we request only what the app's schema declares. ExternalID +// stays empty; the connections page shows a plain "Connected" state and +// the app config page falls back to the provider display name. +// +// Note for admins: a Cloud project whose OAuth consent screen is in +// "Testing" status issues refresh tokens that EXPIRE AFTER 7 DAYS. Publish +// the consent screen to "In production" for durable tokens — staying +// unverified is fine (users click through a warning screen), it just caps +// the app at 100 lifetime users, which doesn't matter for self-hosting. +// See .env.example. +func Google() *Provider { + return &Provider{ + Name: "google", + DisplayName: "Google", + AuthorizeURL: "https://accounts.google.com/o/oauth2/v2/auth", + TokenURL: "https://oauth2.googleapis.com/token", + AuthStyle: oauth2.AuthStyleInParams, + AuthCodeParams: []oauth2.AuthCodeOption{ + oauth2.AccessTypeOffline, + oauth2.SetAuthURLParam("prompt", "consent"), + }, + DefaultScopes: []string{"https://www.googleapis.com/auth/analytics.readonly"}, + } +} diff --git a/internal/connections/google_test.go b/internal/connections/google_test.go new file mode 100644 index 00000000..e0bc6da8 --- /dev/null +++ b/internal/connections/google_test.go @@ -0,0 +1,118 @@ +package connections + +import ( + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "golang.org/x/oauth2" +) + +func TestRegistryMatchesGoogle(t *testing.T) { + r := NewRegistry(Strava(), Spotify(), GitHub(), Google()) + + cases := []struct { + name string + url string + want string // expected provider name; "" means no match + }{ + {"google authorize", "https://accounts.google.com/o/oauth2/v2/auth", "google"}, + {"google with query", "https://accounts.google.com/o/oauth2/v2/auth?client_id=x&scope=y", "google"}, + {"google case mixed", "https://ACCOUNTS.GOOGLE.COM/o/oauth2/v2/auth", "google"}, + {"legacy v1 path same host", "https://accounts.google.com/o/oauth2/auth", "google"}, + {"token host is not the authorize host", "https://oauth2.googleapis.com/token", ""}, + {"unrelated google host", "https://www.googleapis.com/auth/analytics.readonly", ""}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, ok := r.MatchAuthorizeURL(tc.url) + if tc.want == "" { + assert.False(t, ok) + return + } + assert.True(t, ok) + assert.Equal(t, tc.want, got.Name) + }) + } +} + +// TestGoogleAuthCodeURLParams pins the parameters Google's refresh-token +// issuance hinges on: access_type=offline plus prompt=consent, and never +// the legacy approval_prompt, which Google rejects when prompt is +// present. Pinned here (not just via the default options) because the +// provider declares them explicitly in AuthCodeParams. +func TestGoogleAuthCodeURLParams(t *testing.T) { + prov := Google() + cfg := prov.OAuth2Config("id", "secret", "https://tronbyt.example.com/oauth-callback", nil) + + authURL, err := url.Parse(cfg.AuthCodeURL("state123", prov.AuthCodeOptions()...)) + if err != nil { + t.Fatalf("parse auth url: %v", err) + } + q := authURL.Query() + assert.Equal(t, "offline", q.Get("access_type")) + assert.Equal(t, "consent", q.Get("prompt")) + assert.False(t, q.Has("approval_prompt"), + "Google errors when approval_prompt is combined with prompt") +} + +func TestGoogleProviderShape(t *testing.T) { + prov := Google() + cfg := prov.OAuth2Config("id", "secret", "https://tronbyt.example.com/oauth-callback", nil) + + // Confidential client on the redirect flow only: Google's device-flow + // scope allowlist excludes the API scopes apps declare. + assert.True(t, prov.SupportsCodeFlow()) + assert.False(t, prov.SupportsDeviceAuth()) + + // Client credentials go in POST params; token endpoint is the + // dedicated googleapis host. + assert.Equal(t, oauth2.AuthStyleInParams, cfg.Endpoint.AuthStyle) + assert.Equal(t, "https://oauth2.googleapis.com/token", cfg.Endpoint.TokenURL) + + // No Identify: userinfo would need an email/profile scope we don't + // request. + assert.Nil(t, prov.Identify) +} + +// TestGoogleScopePassThrough checks both halves of scope handling: schema +// -declared scopes are requested verbatim (space-joined, no ScopeJoin +// quirk), and the analytics scope serves as the fallback when the schema +// declares none. +func TestGoogleScopePassThrough(t *testing.T) { + prov := Google() + + cases := []struct { + name string + scopes []string + wantScope string + }{ + { + "schema scopes win", + []string{"https://www.googleapis.com/auth/calendar.readonly"}, + "https://www.googleapis.com/auth/calendar.readonly", + }, + { + "multiple scopes space-joined", + []string{"https://www.googleapis.com/auth/analytics.readonly", "openid"}, + "https://www.googleapis.com/auth/analytics.readonly openid", + }, + { + "defaults when schema declares none", + nil, + "https://www.googleapis.com/auth/analytics.readonly", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg := prov.OAuth2Config("id", "secret", "https://tronbyt.example.com/oauth-callback", tc.scopes) + authURL, err := url.Parse(cfg.AuthCodeURL("state123", prov.AuthCodeOptions()...)) + if err != nil { + t.Fatalf("parse auth url: %v", err) + } + assert.Equal(t, tc.wantScope, authURL.Query().Get("scope")) + }) + } +} diff --git a/internal/connections/provider.go b/internal/connections/provider.go new file mode 100644 index 00000000..0c7fb34f --- /dev/null +++ b/internal/connections/provider.go @@ -0,0 +1,201 @@ +package connections + +import ( + "context" + "errors" + "fmt" + "net/url" + "strings" + "time" + + "golang.org/x/oauth2" +) + +// Provider describes one third-party OAuth2 provider. +// +// Each provider declares its OAuth2 endpoints, the env-var names that hold +// its credentials (the *server admin* registers their own OAuth app and +// supplies these), and an optional fetcher for the provider's user-id / +// display name. The fetcher runs once at connection time so we can show a +// useful label like "athlete #1234567" in the UI. +type Provider struct { + // Name is the stable identifier used in URLs and DB rows: "strava". + Name string + + // DisplayName is shown in the UI: "Strava". + DisplayName string + + // AuthorizeURL / TokenURL are the provider's OAuth2 endpoints. AuthorizeURL + // is also matched (by host) when we look at a pixlet schema.OAuth2 field + // to decide which provider it refers to. + AuthorizeURL string + TokenURL string + + // AuthStyle controls how client credentials reach the token endpoint. + // The zero value (AutoDetect) probes header-then-params and caches the + // winner; set it explicitly when the provider documents one style + // (Strava: params; Spotify: Basic header). + AuthStyle oauth2.AuthStyle + + // ScopeJoin, when non-empty, collapses the scope list into a single + // element joined by this separator before the authorize redirect. + // Strava wants "read,activity:read" — one comma-joined value — while + // the oauth2 library would otherwise join multiple scopes with spaces. + ScopeJoin string + + // DeviceAuthURL enables the RFC 8628 device authorization grant: the + // user is shown a short code (on the web page and on the display + // itself) and approves it on a phone or laptop. No redirect URI is + // involved, which makes it the natural fit for a device on a shelf. + // Empty means the provider offers no device flow. + DeviceAuthURL string + + // DeviceAuthNeedsSecret marks providers that still require the client + // secret at the device-flow token exchange (Trakt, Google). True + // public clients (GitHub, Microsoft) need only a client id, so the + // admin can enable them without configuring a secret at all. + DeviceAuthNeedsSecret bool + + // DefaultScopes is used when the schema does not declare scopes. + DefaultScopes []string + + // AuthCodeParams overrides the extra query parameters sent on the + // authorize redirect. When nil, AuthCodeOptions falls back to + // oauth2.AccessTypeOffline + oauth2.ApprovalForce, which today spell + // access_type=offline + prompt=consent. Set this when a provider + // needs exact parameters (Google's refresh-token issuance hinges on + // these two, so it pins them rather than track the library constants' + // spelling — ApprovalForce has already changed once, from the legacy + // approval_prompt=force). An empty non-nil slice means "no extra + // parameters". + AuthCodeParams []oauth2.AuthCodeOption + + // Identify is called after a successful token exchange to fetch a + // stable provider-side user id (and optional display name). It receives + // a fresh access token. Optional — if nil, ExternalID stays empty. + Identify func(ctx context.Context, accessToken string) (externalID, displayName string, err error) +} + +// OAuth2Config builds a *oauth2.Config tied to a specific runtime redirect URI. +// Caller supplies clientID/clientSecret (loaded from env per provider). +func (p *Provider) OAuth2Config(clientID, clientSecret, redirectURL string, scopes []string) *oauth2.Config { + if len(scopes) == 0 { + scopes = p.DefaultScopes + } + if p.ScopeJoin != "" && len(scopes) > 1 { + scopes = []string{strings.Join(scopes, p.ScopeJoin)} + } + return &oauth2.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + RedirectURL: redirectURL, + Scopes: scopes, + Endpoint: oauth2.Endpoint{ + AuthURL: p.AuthorizeURL, + TokenURL: p.TokenURL, + DeviceAuthURL: p.DeviceAuthURL, + AuthStyle: p.AuthStyle, + }, + } +} + +// AuthCodeOptions returns the oauth2.AuthCodeOption list for the +// authorize redirect: offline access plus a forced consent screen, so a +// re-connect always yields a fresh refresh token. Providers set +// AuthCodeParams when the default parameter spelling doesn't work for +// them (see that field's comment). +func (p *Provider) AuthCodeOptions() []oauth2.AuthCodeOption { + if p.AuthCodeParams != nil { + return p.AuthCodeParams + } + return []oauth2.AuthCodeOption{oauth2.AccessTypeOffline, oauth2.ApprovalForce} +} + +// SupportsDeviceAuth reports whether this provider offers the device +// authorization grant. +func (p *Provider) SupportsDeviceAuth() bool { + return p.DeviceAuthURL != "" +} + +// SupportsCodeFlow reports whether this provider offers the browser +// redirect (authorization code) flow. A device-flow-only provider — a +// public client with no registered redirect URI — sets AuthorizeURL +// empty. +func (p *Provider) SupportsCodeFlow() bool { + return p.AuthorizeURL != "" +} + +// Registry indexes providers by name and looks them up by an +// authorization_endpoint URL declared in a pixlet schema.OAuth2 field. +type Registry struct { + byName map[string]*Provider + byHost map[string]*Provider +} + +// NewRegistry builds a registry from the given providers. +func NewRegistry(providers ...*Provider) *Registry { + r := &Registry{ + byName: make(map[string]*Provider, len(providers)), + byHost: make(map[string]*Provider, len(providers)), + } + for _, p := range providers { + r.byName[p.Name] = p + if u, err := url.Parse(p.AuthorizeURL); err == nil && u.Host != "" { + r.byHost[strings.ToLower(u.Host)] = p + } + } + return r +} + +// Get looks up a provider by its stable name (e.g. "strava"). +func (r *Registry) Get(name string) (*Provider, bool) { + p, ok := r.byName[strings.ToLower(name)] + return p, ok +} + +// All returns providers in registration order is not guaranteed; use Names() for a +// deterministic listing if needed. +func (r *Registry) All() []*Provider { + out := make([]*Provider, 0, len(r.byName)) + for _, p := range r.byName { + out = append(out, p) + } + return out +} + +// MatchAuthorizeURL returns the provider whose AuthorizeURL host matches the +// given URL. This is how we decide which provider a pixlet schema.OAuth2 +// field refers to without modifying the field shape. +func (r *Registry) MatchAuthorizeURL(rawURL string) (*Provider, bool) { + u, err := url.Parse(rawURL) + if err != nil || u.Host == "" { + return nil, false + } + p, ok := r.byHost[strings.ToLower(u.Host)] + return p, ok +} + +// ErrProviderDisabled is returned when the admin hasn't supplied client +// credentials for a provider. +var ErrProviderDisabled = errors.New("connections: provider not configured (missing client id/secret)") + +// Token represents a freshly-exchanged or refreshed OAuth2 token plus any +// provider-supplied athlete/external id. +type Token struct { + AccessToken string + RefreshToken string + Expiry time.Time + Scopes []string + + ExternalID string + DisplayName string +} + +// formatExternalID exists so providers can produce a friendly default for +// DisplayName when their API doesn't return a name. +func formatExternalID(provider, id string) string { + if id == "" { + return "" + } + return fmt.Sprintf("%s #%s", provider, id) +} diff --git a/internal/connections/provider_test.go b/internal/connections/provider_test.go new file mode 100644 index 00000000..2abeaabf --- /dev/null +++ b/internal/connections/provider_test.go @@ -0,0 +1,110 @@ +package connections + +import ( + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "golang.org/x/oauth2" +) + +func TestRegistryGetByName(t *testing.T) { + r := NewRegistry(Strava()) + + p, ok := r.Get("strava") + assert.True(t, ok) + assert.Equal(t, "strava", p.Name) + + // Case-insensitive lookup. + p, ok = r.Get("STRAVA") + assert.True(t, ok) + assert.Equal(t, "strava", p.Name) + + _, ok = r.Get("nope") + assert.False(t, ok) +} + +// TestAuthCodeOptions pins the per-provider authorize-redirect parameters +// the connect handler sends: the offline+consent default when +// AuthCodeParams is nil (via oauth2.AccessTypeOffline/ApprovalForce, +// which today spell access_type=offline and prompt=consent), a +// provider's own list when set, and nothing extra for an empty non-nil +// slice. +func TestAuthCodeOptions(t *testing.T) { + cases := []struct { + name string + provider *Provider + wantParams map[string]string // expected query params on the auth URL + absent []string // params that must NOT appear + }{ + { + "nil params get the offline+consent default", + Strava(), + map[string]string{"access_type": "offline", "prompt": "consent"}, + []string{"approval_prompt"}, + }, + { + "provider-specific params replace the default", + Google(), + map[string]string{"access_type": "offline", "prompt": "consent"}, + []string{"approval_prompt"}, + }, + { + "empty non-nil slice means no extra params", + &Provider{ + Name: "bare", + AuthorizeURL: "https://example.com/authorize", + TokenURL: "https://example.com/token", + AuthCodeParams: []oauth2.AuthCodeOption{}, + }, + map[string]string{}, + []string{"access_type", "approval_prompt", "prompt"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg := tc.provider.OAuth2Config("id", "secret", "https://tronbyt.example.com/oauth-callback", nil) + authURL, err := url.Parse(cfg.AuthCodeURL("state123", tc.provider.AuthCodeOptions()...)) + if err != nil { + t.Fatalf("parse auth url: %v", err) + } + q := authURL.Query() + for k, v := range tc.wantParams { + assert.Equal(t, v, q.Get(k), "param %s", k) + } + for _, k := range tc.absent { + assert.False(t, q.Has(k), "param %s must be absent", k) + } + }) + } +} + +func TestRegistryMatchAuthorizeURL(t *testing.T) { + r := NewRegistry(Strava()) + + cases := []struct { + name string + url string + want string // expected provider name; "" means no match + }{ + {"strava authorize", "https://www.strava.com/oauth/authorize", "strava"}, + {"strava with query", "https://www.strava.com/oauth/authorize?foo=bar", "strava"}, + {"strava case mixed", "https://WWW.STRAVA.COM/oauth/authorize", "strava"}, + {"unrelated host", "https://accounts.spotify.com/authorize", ""}, + {"garbage", "not-a-url", ""}, + {"empty", "", ""}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, ok := r.MatchAuthorizeURL(tc.url) + if tc.want == "" { + assert.False(t, ok) + return + } + assert.True(t, ok) + assert.Equal(t, tc.want, got.Name) + }) + } +} diff --git a/internal/connections/service.go b/internal/connections/service.go new file mode 100644 index 00000000..246ed047 --- /dev/null +++ b/internal/connections/service.go @@ -0,0 +1,424 @@ +package connections + +import ( + "context" + "errors" + "fmt" + "log/slog" + "strings" + "sync" + "time" + + "tronbyt-server/internal/data" + + "golang.org/x/oauth2" + "gorm.io/gorm" +) + +// Service is the high-level façade the HTTP handlers and renderer call. +// +// It reads provider client credentials via GetCreds, persists encrypted +// tokens, and refreshes them on demand. The encryption secret is the +// server's session secret_key (already used to sign session cookies); +// the same key the rest of the server treats as load-bearing. +type Service struct { + DB *gorm.DB + Registry *Registry + Secret string // session secret_key for token encryption + GetCreds CredentialsFunc // admin-provided client id/secret per provider + + // refreshMu guards refreshLocks; refreshLocks single-flights token + // refreshes per connection. Providers like Strava rotate the refresh + // token on every use (the old one dies immediately), so two renders + // refreshing concurrently must serialize or a lost race can persist a + // dead refresh token. + refreshMu sync.Mutex + refreshLocks map[uint]*sync.Mutex +} + +// connectionLock returns the per-connection mutex, creating it on first use. +func (s *Service) connectionLock(id uint) *sync.Mutex { + s.refreshMu.Lock() + defer s.refreshMu.Unlock() + if s.refreshLocks == nil { + s.refreshLocks = make(map[uint]*sync.Mutex) + } + m, ok := s.refreshLocks[id] + if !ok { + m = &sync.Mutex{} + s.refreshLocks[id] = m + } + return m +} + +// CredentialsFunc returns the OAuth client_id / client_secret for a provider, +// typically from server config / env vars. Returning ("", "") means the +// admin hasn't enabled this provider — handlers should treat that as +// ErrProviderDisabled. +type CredentialsFunc func(provider string) (clientID, clientSecret string) + +// Errors surfaced to the HTTP layer. +var ( + ErrConnectionNotFound = errors.New("connections: not found") +) + +// ExchangeCode is called from the OAuth callback. It exchanges the +// authorization code for tokens, optionally identifies the user against the +// provider, then upserts the Connection row for this (user, provider). +func (s *Service) ExchangeCode(ctx context.Context, providerName, username, redirectURL, code string, requestedScopes []string) (*data.Connection, error) { + provider, ok := s.Registry.Get(providerName) + if !ok { + return nil, fmt.Errorf("connections: unknown provider %q", providerName) + } + + clientID, clientSecret := s.GetCreds(provider.Name) + if clientID == "" || clientSecret == "" { + return nil, ErrProviderDisabled + } + + cfg := provider.OAuth2Config(clientID, clientSecret, redirectURL, requestedScopes) + tok, err := cfg.Exchange(ctx, code) + if err != nil { + return nil, fmt.Errorf("connections: token exchange: %w", err) + } + + externalID, displayName := "", "" + if provider.Identify != nil && tok.AccessToken != "" { + if eid, dn, err := provider.Identify(ctx, tok.AccessToken); err != nil { + slog.Warn("connections: identify failed, continuing", "provider", provider.Name, "error", err) + } else { + externalID, displayName = eid, dn + } + } + + return s.upsertConnection(ctx, provider, username, tok, externalID, displayName, requestedScopes) +} + +// upsertConnection writes (or replaces) the user's Connection for this +// provider and returns the persisted row. +func (s *Service) upsertConnection(ctx context.Context, provider *Provider, username string, tok *oauth2.Token, externalID, displayName string, scopes []string) (*data.Connection, error) { + sealedAccess, err := Seal(s.Secret, []byte(tok.AccessToken)) + if err != nil { + return nil, fmt.Errorf("connections: seal access token: %w", err) + } + sealedRefresh, err := Seal(s.Secret, []byte(tok.RefreshToken)) + if err != nil { + return nil, fmt.Errorf("connections: seal refresh token: %w", err) + } + + // Prefer the scopes the provider says it GRANTED over the ones we + // asked for: consent screens let users uncheck individual scopes + // (Strava's does), so the requested set is not what the token can + // actually do. Fall back to the request only when the provider + // reports nothing. + scopeStr := "" + if v, ok := tok.Extra("scope").(string); ok && v != "" { + scopeStr = strings.Join(strings.FieldsFunc(v, func(r rune) bool { + return r == ',' || r == ' ' + }), " ") + } + if scopeStr == "" { + scopeStr = strings.Join(scopes, " ") + } + + existing, err := gorm.G[data.Connection](s.DB). + Where("user_id = ? AND provider = ?", username, provider.Name). + First(ctx) + + conn := data.Connection{ + UserID: username, + Provider: provider.Name, + ExternalID: externalID, + DisplayName: displayName, + Scopes: scopeStr, + AccessToken: sealedAccess, + RefreshToken: sealedRefresh, + AccessExpiresAt: tok.Expiry, + } + + switch { + case errors.Is(err, gorm.ErrRecordNotFound): + if err := gorm.G[data.Connection](s.DB).Create(ctx, &conn); err != nil { + return nil, fmt.Errorf("connections: create: %w", err) + } + return &conn, nil + case err != nil: + return nil, fmt.Errorf("connections: lookup: %w", err) + default: + conn.ID = existing.ID + conn.CreatedAt = existing.CreatedAt + // If the provider didn't echo a fresh refresh token (some don't on + // every exchange), keep the existing one. + if tok.RefreshToken == "" && len(existing.RefreshToken) > 0 { + conn.RefreshToken = existing.RefreshToken + } + // A re-connect where Identify failed (or granted-scope reporting was + // absent) must not clobber the identity we already know. + if conn.ExternalID == "" { + conn.ExternalID = existing.ExternalID + } + if conn.DisplayName == "" { + conn.DisplayName = existing.DisplayName + } + if conn.Scopes == "" { + conn.Scopes = existing.Scopes + } + if err := s.DB.Save(&conn).Error; err != nil { + return nil, fmt.Errorf("connections: update: %w", err) + } + return &conn, nil + } +} + +// AccessTokenForUser returns a usable access token for (user, provider), +// transparently refreshing it if it has expired or is close to expiry. It +// returns ErrConnectionNotFound if the user has not connected this +// provider. +func (s *Service) AccessTokenForUser(ctx context.Context, providerName, username string) (string, error) { + provider, ok := s.Registry.Get(providerName) + if !ok { + return "", fmt.Errorf("connections: unknown provider %q", providerName) + } + + conn, err := gorm.G[data.Connection](s.DB). + Where("user_id = ? AND provider = ?", username, providerName). + First(ctx) + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", ErrConnectionNotFound + } + if err != nil { + return "", err + } + + access, err := Open(s.Secret, conn.AccessToken) + if err != nil { + return "", fmt.Errorf("connections: open access token: %w", err) + } + + if staleToken(access, conn.AccessExpiresAt) { + refreshed, rerr := s.refresh(ctx, provider, &conn) + if rerr != nil { + return "", rerr + } + return refreshed, nil + } + return string(access), nil +} + +// staleToken reports whether the stored access token needs a refresh: +// missing, expired, or expiring within 60 seconds (so a render that takes a +// few seconds doesn't get a 401). A zero expiry follows the x/oauth2 +// convention — the token never expires — because providers that omit +// expires_in typically also issue no refresh token, and treating zero as +// "expired" would refresh (and fail) on every render forever. +func staleToken(access []byte, expiresAt time.Time) bool { + if len(access) == 0 { + return true + } + return !expiresAt.IsZero() && time.Until(expiresAt) < 60*time.Second +} + +// refreshCreds returns the credentials to use for a refresh grant. A +// public client connected by device flow has no secret configured, and +// refreshing such a token doesn't need one — requiring a secret here +// would break the connection the first time its access token expired +// (GitHub now issues expiring tokens to new OAuth apps by default). +func (s *Service) refreshCreds(provider *Provider) (clientID, clientSecret string, ok bool) { + clientID, clientSecret = s.GetCreds(provider.Name) + if clientID == "" { + return "", "", false + } + publicClient := provider.SupportsDeviceAuth() && !provider.DeviceAuthNeedsSecret + if clientSecret == "" && !publicClient { + return "", "", false + } + return clientID, clientSecret, true +} + +// refresh performs a refresh-token exchange and persists the new tokens. +// It single-flights per connection: with rotating-refresh-token providers +// (Strava), concurrent refreshes are a correctness hazard, not just wasted +// work. The loser of the race re-reads the row and returns the winner's +// fresh token instead of spending the (now dead) old refresh token. +func (s *Service) refresh(ctx context.Context, provider *Provider, conn *data.Connection) (string, error) { + lock := s.connectionLock(conn.ID) + lock.Lock() + defer lock.Unlock() + + // Re-read under the lock: another goroutine may have refreshed while we + // waited, in which case the stored token is already fresh. + if current, err := gorm.G[data.Connection](s.DB). + Where("id = ?", conn.ID). + First(ctx); err == nil { + access, aerr := Open(s.Secret, current.AccessToken) + if aerr == nil && !staleToken(access, current.AccessExpiresAt) { + return string(access), nil + } + conn = ¤t + } + + refresh, err := Open(s.Secret, conn.RefreshToken) + if err != nil { + return "", fmt.Errorf("connections: open refresh token: %w", err) + } + if len(refresh) == 0 { + return "", fmt.Errorf("connections: no refresh token stored, reconnect required") + } + + clientID, clientSecret, ok := s.refreshCreds(provider) + if !ok { + return "", ErrProviderDisabled + } + + cfg := provider.OAuth2Config(clientID, clientSecret, "", nil) + src := cfg.TokenSource(ctx, &oauth2.Token{RefreshToken: string(refresh)}) + tok, err := src.Token() + if err != nil { + return "", fmt.Errorf("connections: refresh: %w", err) + } + + sealedAccess, err := Seal(s.Secret, []byte(tok.AccessToken)) + if err != nil { + return "", err + } + updates := data.Connection{ + ID: conn.ID, + AccessToken: sealedAccess, + AccessExpiresAt: tok.Expiry, + } + q := gorm.G[data.Connection](s.DB).Where("id = ?", conn.ID) + if tok.RefreshToken != "" && tok.RefreshToken != string(refresh) { + sealedRefresh, err := Seal(s.Secret, []byte(tok.RefreshToken)) + if err != nil { + return "", err + } + updates.RefreshToken = sealedRefresh + q = q.Select("AccessToken", "AccessExpiresAt", "RefreshToken") + } else { + q = q.Select("AccessToken", "AccessExpiresAt") + } + if _, err := q.Updates(ctx, updates); err != nil { + return "", fmt.Errorf("connections: persist refreshed token: %w", err) + } + return tok.AccessToken, nil +} + +// CodeFlowAvailable reports whether the admin has supplied what the +// browser-redirect flow needs for this provider (id *and* secret). +func (s *Service) CodeFlowAvailable(provider *Provider) bool { + if !provider.SupportsCodeFlow() { + return false + } + clientID, clientSecret := s.GetCreds(provider.Name) + return clientID != "" && clientSecret != "" +} + +// DeviceFlowAvailable reports whether the device authorization grant can +// run for this provider. Public-client providers need only a client id, +// so an admin can enable GitHub with a single env var. +func (s *Service) DeviceFlowAvailable(provider *Provider) bool { + if !provider.SupportsDeviceAuth() { + return false + } + clientID, clientSecret := s.GetCreds(provider.Name) + if clientID == "" { + return false + } + return clientSecret != "" || !provider.DeviceAuthNeedsSecret +} + +// deviceConfig builds the oauth2 config for a device-flow exchange. The +// client secret is withheld from providers that don't want one, so a +// public-client exchange stays a public-client exchange. +// +// AuthStyle is forced to AuthStyleInParams. The library's auto-detect +// would send Basic auth with an empty password for a secretless client, +// and — because it only caches the style on success, while every pending +// poll is an "error" — it would probe both styles on *every* tick, +// doubling the request rate against the provider's rate limit. +func (s *Service) deviceConfig(provider *Provider, scopes []string) *oauth2.Config { + clientID, clientSecret := s.GetCreds(provider.Name) + if !provider.DeviceAuthNeedsSecret { + clientSecret = "" + } + cfg := provider.OAuth2Config(clientID, clientSecret, "", scopes) + cfg.Endpoint.AuthStyle = oauth2.AuthStyleInParams + return cfg +} + +// StartDeviceAuth requests a user code from the provider. It returns +// immediately — the returned response carries the code to show the user, +// the URL they visit, and how long they have. Polling happens separately +// in CompleteDeviceAuth. +func (s *Service) StartDeviceAuth(ctx context.Context, providerName string, scopes []string) (*Provider, *oauth2.DeviceAuthResponse, error) { + provider, ok := s.Registry.Get(providerName) + if !ok { + return nil, nil, fmt.Errorf("connections: unknown provider %q", providerName) + } + if !s.DeviceFlowAvailable(provider) { + return nil, nil, ErrProviderDisabled + } + + resp, err := s.deviceConfig(provider, scopes).DeviceAuth(ctx) + if err != nil { + return nil, nil, fmt.Errorf("connections: device authorization request: %w", err) + } + return provider, resp, nil +} + +// CompleteDeviceAuth polls the provider's token endpoint until the user +// approves, denies, or the code expires, then persists the connection. +// It BLOCKS for as long as the code is valid (typically 5–15 minutes), +// so callers run it in a goroutine and report progress out of band. +func (s *Service) CompleteDeviceAuth(ctx context.Context, providerName, username string, da *oauth2.DeviceAuthResponse, scopes []string) (*data.Connection, error) { + provider, ok := s.Registry.Get(providerName) + if !ok { + return nil, fmt.Errorf("connections: unknown provider %q", providerName) + } + if !s.DeviceFlowAvailable(provider) { + return nil, ErrProviderDisabled + } + + tok, err := s.deviceConfig(provider, scopes).DeviceAccessToken(ctx, da) + if err != nil { + return nil, fmt.Errorf("connections: device token exchange: %w", err) + } + + externalID, displayName := "", "" + if provider.Identify != nil && tok.AccessToken != "" { + if eid, dn, err := provider.Identify(ctx, tok.AccessToken); err != nil { + slog.Warn("connections: identify failed, continuing", "provider", provider.Name, "error", err) + } else { + externalID, displayName = eid, dn + } + } + + return s.upsertConnection(ctx, provider, username, tok, externalID, displayName, scopes) +} + +// ListForUser returns all of a user's connections, sorted by provider. +func (s *Service) ListForUser(ctx context.Context, username string) ([]data.Connection, error) { + conns, err := gorm.G[data.Connection](s.DB). + Where("user_id = ?", username). + Order("provider ASC"). + Find(ctx) + if err != nil { + return nil, err + } + return conns, nil +} + +// Disconnect deletes a connection. The caller must verify ownership before +// calling. +func (s *Service) Disconnect(ctx context.Context, id uint, username string) error { + rows, err := gorm.G[data.Connection](s.DB). + Where("id = ? AND user_id = ?", id, username). + Delete(ctx) + if err != nil { + return err + } + if rows == 0 { + return ErrConnectionNotFound + } + return nil +} diff --git a/internal/connections/service_refresh_test.go b/internal/connections/service_refresh_test.go new file mode 100644 index 00000000..95b75997 --- /dev/null +++ b/internal/connections/service_refresh_test.go @@ -0,0 +1,153 @@ +package connections + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "tronbyt-server/internal/data" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestAccessTokenForUserNonExpiringToken covers providers that omit +// expires_in: the token never expires, so it must be served from storage +// rather than triggering a refresh on every call (which would fail +// permanently when such a provider also issues no refresh token). +func TestAccessTokenForUserNonExpiringToken(t *testing.T) { + fp, prov := newFakeProvider(t, "42") + fp.expiresIn = 0 // provider omits expires_in + fp.nextRefresh = func() string { return "" } // ...and issues no refresh token + svc, _ := newTestService(t, prov, "test-secret") + + _, err := svc.ExchangeCode(context.Background(), prov.Name, "alice", "http://localhost/oauth-callback", "code", nil) + require.NoError(t, err) + require.Equal(t, int32(1), fp.tokenHits.Load()) + + for range 3 { + tok, err := svc.AccessTokenForUser(context.Background(), prov.Name, "alice") + require.NoError(t, err) + assert.Equal(t, "access-AAA", tok) + } + + assert.Equal(t, int32(1), fp.tokenHits.Load(), + "a token with no expiry must not be refreshed on every call") +} + +// TestRefreshIsSingleFlighted guards providers that rotate refresh tokens +// (Strava invalidates the old one immediately): concurrent renders must +// not each spend the stored refresh token. +func TestRefreshIsSingleFlighted(t *testing.T) { + fp, prov := newFakeProvider(t, "42") + svc, db := newTestService(t, prov, "test-secret") + + // The shared in-memory DB is per-connection, so pin the pool to one + // connection before running concurrent callers against it. + sqlDB, err := db.DB() + require.NoError(t, err) + sqlDB.SetMaxOpenConns(1) + + _, err = svc.ExchangeCode(context.Background(), prov.Name, "alice", "http://localhost/oauth-callback", "code", nil) + require.NoError(t, err) + require.Equal(t, int32(1), fp.tokenHits.Load()) + + // Age the stored token past expiry so every caller sees it as stale. + require.NoError(t, db.Model(&data.Connection{}). + Where("user_id = ?", "alice"). + Update("access_expires_at", time.Now().Add(-time.Hour)).Error) + + fp.nextAccess = func() string { return "access-BBB" } + fp.nextRefresh = func() string { return "refresh-BBB" } + + const callers = 8 + var wg sync.WaitGroup + tokens := make([]string, callers) + errs := make([]error, callers) + for i := range callers { + wg.Go(func() { + tokens[i], errs[i] = svc.AccessTokenForUser(context.Background(), prov.Name, "alice") + }) + } + wg.Wait() + + for i := range callers { + require.NoError(t, errs[i]) + assert.Equal(t, "access-BBB", tokens[i]) + } + assert.Equal(t, int32(2), fp.tokenHits.Load(), + "exactly one refresh should follow the initial exchange") +} + +// TestRefreshWorksForSecretlessPublicClient covers a device-flow provider +// like GitHub, which the admin enables with a client id alone. New GitHub +// OAuth apps issue expiring tokens by default, so the refresh path has to +// work without a client secret or the connection dies after 8 hours. +func TestRefreshWorksForSecretlessPublicClient(t *testing.T) { + fp, prov := newFakeProvider(t, "42") + prov.DeviceAuthURL = fp.server.URL + "/device/code" // marks it a device-flow provider + prov.DeviceAuthNeedsSecret = false + + svc, db := newTestService(t, prov, "test-secret") + + _, err := svc.ExchangeCode(context.Background(), prov.Name, "alice", "http://localhost/oauth-callback", "code", nil) + require.NoError(t, err) + + require.NoError(t, db.Model(&data.Connection{}). + Where("user_id = ?", "alice"). + Update("access_expires_at", time.Now().Add(-time.Hour)).Error) + + // As an admin who enabled this provider with a client id alone would + // have it: no secret available at refresh time. + svc.GetCreds = func(string) (string, string) { return "public-client-id", "" } + fp.nextAccess = func() string { return "access-REFRESHED" } + tok, err := svc.AccessTokenForUser(context.Background(), prov.Name, "alice") + require.NoError(t, err, "a secretless public client must still be able to refresh") + assert.Equal(t, "access-REFRESHED", tok) +} + +// TestRefreshRequiresSecretForConfidentialClient is the other half: a +// redirect-flow provider with no secret stays disabled. +func TestRefreshRequiresSecretForConfidentialClient(t *testing.T) { + fp, prov := newFakeProvider(t, "42") + svc, db := newTestService(t, prov, "test-secret") + + _, err := svc.ExchangeCode(context.Background(), prov.Name, "alice", "http://localhost/oauth-callback", "code", nil) + require.NoError(t, err) + require.NoError(t, db.Model(&data.Connection{}). + Where("user_id = ?", "alice"). + Update("access_expires_at", time.Now().Add(-time.Hour)).Error) + + svc.GetCreds = func(string) (string, string) { return "id-only", "" } + fp.nextAccess = func() string { return "should-not-be-reached" } + + _, err = svc.AccessTokenForUser(context.Background(), prov.Name, "alice") + assert.ErrorIs(t, err, ErrProviderDisabled) +} + +// TestExchangeCodeKeepsIdentityWhenIdentifyFails covers re-connecting when +// the provider's identify call fails: the previously captured athlete id +// and display name must survive rather than being blanked by the upsert. +func TestExchangeCodeKeepsIdentityWhenIdentifyFails(t *testing.T) { + fp, prov := newFakeProvider(t, "42") + svc, _ := newTestService(t, prov, "test-secret") + + first, err := svc.ExchangeCode(context.Background(), prov.Name, "alice", "http://localhost/oauth-callback", "code-1", []string{"read"}) + require.NoError(t, err) + require.Equal(t, "42", first.ExternalID) + require.Equal(t, "Fake User #42", first.DisplayName) + + prov.Identify = func(context.Context, string) (string, string, error) { + return "", "", errors.New("identify unavailable") + } + fp.nextAccess = func() string { return "access-BBB" } + + second, err := svc.ExchangeCode(context.Background(), prov.Name, "alice", "http://localhost/oauth-callback", "code-2", nil) + require.NoError(t, err) + assert.Equal(t, first.ID, second.ID) + assert.Equal(t, "42", second.ExternalID, "identity must survive a failed re-identify") + assert.Equal(t, "Fake User #42", second.DisplayName) + assert.Equal(t, "read", second.Scopes, "granted scopes must survive a re-connect that reports none") +} diff --git a/internal/connections/service_test.go b/internal/connections/service_test.go new file mode 100644 index 00000000..e81f6996 --- /dev/null +++ b/internal/connections/service_test.go @@ -0,0 +1,300 @@ +package connections + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "sync/atomic" + "testing" + "time" + + "tronbyt-server/internal/data" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +// fakeProvider stands up an httptest server that mimics a token endpoint +// (and optionally an identify endpoint), then returns a Provider pointing +// at it. +type fakeProvider struct { + server *httptest.Server + + // Tunable per-test: + // nextAccess returns the access token to issue on the next exchange/refresh. + nextAccess func() string + // nextRefresh returns the refresh token to echo back. If empty string, + // the response omits refresh_token (some real providers do this). + nextRefresh func() string + // expiresIn is the seconds-from-now used in token responses. + expiresIn int + + // Hits records each call type, useful for asserting refresh happened. + tokenHits atomic.Int32 + identifyHits atomic.Int32 +} + +func newFakeProvider(t *testing.T, externalID string) (*fakeProvider, *Provider) { + t.Helper() + fp := &fakeProvider{ + expiresIn: 3600, + nextAccess: func() string { return "access-AAA" }, + nextRefresh: func() string { return "refresh-AAA" }, + } + + mux := http.NewServeMux() + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + fp.tokenHits.Add(1) + + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + // Sanity-check standard params for both grant types. + grant := r.PostForm.Get("grant_type") + switch grant { + case "authorization_code": + if r.PostForm.Get("code") == "" { + http.Error(w, "missing code", http.StatusBadRequest) + return + } + case "refresh_token": + if r.PostForm.Get("refresh_token") == "" { + http.Error(w, "missing refresh_token", http.StatusBadRequest) + return + } + default: + http.Error(w, "unsupported grant", http.StatusBadRequest) + return + } + + body := map[string]any{ + "access_token": fp.nextAccess(), + "token_type": "Bearer", + "expires_in": fp.expiresIn, + } + if rt := fp.nextRefresh(); rt != "" { + body["refresh_token"] = rt + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(body) + }) + mux.HandleFunc("/identify", func(w http.ResponseWriter, r *http.Request) { + fp.identifyHits.Add(1) + _ = json.NewEncoder(w).Encode(map[string]any{"id": externalID}) + }) + + fp.server = httptest.NewServer(mux) + t.Cleanup(fp.server.Close) + + prov := &Provider{ + Name: "fake", + DisplayName: "Fake", + AuthorizeURL: fp.server.URL + "/authorize", + TokenURL: fp.server.URL + "/token", + DefaultScopes: []string{"read"}, + Identify: func(ctx context.Context, accessToken string) (string, string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fp.server.URL+"/identify", nil) + if err != nil { + return "", "", err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", "", err + } + defer func() { _ = resp.Body.Close() }() + var body struct { + ID string `json:"id"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return "", "", err + } + return body.ID, "Fake User #" + body.ID, nil + }, + } + return fp, prov +} + +func newTestService(t *testing.T, prov *Provider, secret string) (*Service, *gorm.DB) { + t.Helper() + db, err := gorm.Open(sqlite.Open("file::memory:?cache=private"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&data.User{}, &data.Connection{})) + + require.NoError(t, db.Create(&data.User{Username: "alice", APIKey: "k"}).Error) + + svc := &Service{ + DB: db, + Registry: NewRegistry(prov), + Secret: secret, + GetCreds: func(provider string) (string, string) { + if provider == prov.Name { + return "client-id-stub", "client-secret-stub" + } + return "", "" + }, + } + return svc, db +} + +func TestExchangeCodeCreatesEncryptedConnection(t *testing.T) { + fp, prov := newFakeProvider(t, "42") + svc, db := newTestService(t, prov, "test-secret") + + conn, err := svc.ExchangeCode(context.Background(), prov.Name, "alice", "http://localhost/oauth-callback", "auth-code", []string{"read"}) + require.NoError(t, err) + require.NotNil(t, conn) + assert.Equal(t, "alice", conn.UserID) + assert.Equal(t, prov.Name, conn.Provider) + assert.Equal(t, "42", conn.ExternalID) + assert.Equal(t, "Fake User #42", conn.DisplayName) + assert.Equal(t, int32(1), fp.tokenHits.Load()) + assert.Equal(t, int32(1), fp.identifyHits.Load()) + + // Tokens must be encrypted in the DB row, not stored in cleartext. + var row data.Connection + require.NoError(t, db.First(&row, conn.ID).Error) + assert.NotContains(t, string(row.AccessToken), "access-AAA") + assert.NotContains(t, string(row.RefreshToken), "refresh-AAA") + + // And we can decrypt them back. + access, err := Open(svc.Secret, row.AccessToken) + require.NoError(t, err) + assert.Equal(t, "access-AAA", string(access)) + refresh, err := Open(svc.Secret, row.RefreshToken) + require.NoError(t, err) + assert.Equal(t, "refresh-AAA", string(refresh)) +} + +func TestExchangeCodeUpsertsExisting(t *testing.T) { + fp, prov := newFakeProvider(t, "42") + svc, db := newTestService(t, prov, "test-secret") + + first, err := svc.ExchangeCode(context.Background(), prov.Name, "alice", "http://localhost/oauth-callback", "code-1", nil) + require.NoError(t, err) + + // Reconnecting (same user, same provider) updates the row in place. + fp.nextAccess = func() string { return "access-BBB" } + fp.nextRefresh = func() string { return "refresh-BBB" } + second, err := svc.ExchangeCode(context.Background(), prov.Name, "alice", "http://localhost/oauth-callback", "code-2", nil) + require.NoError(t, err) + assert.Equal(t, first.ID, second.ID, "should not create a duplicate row for the same (user, provider)") + + var rows []data.Connection + require.NoError(t, db.Find(&rows).Error) + require.Len(t, rows, 1) +} + +func TestAccessTokenForUserUsesFreshTokenWithoutRefresh(t *testing.T) { + fp, prov := newFakeProvider(t, "42") + svc, _ := newTestService(t, prov, "test-secret") + + _, err := svc.ExchangeCode(context.Background(), prov.Name, "alice", "http://localhost/oauth-callback", "code", nil) + require.NoError(t, err) + require.Equal(t, int32(1), fp.tokenHits.Load()) + + tok, err := svc.AccessTokenForUser(context.Background(), prov.Name, "alice") + require.NoError(t, err) + assert.Equal(t, "access-AAA", tok) + + // No additional token call; the fresh token was reused. + assert.Equal(t, int32(1), fp.tokenHits.Load()) +} + +func TestAccessTokenForUserRefreshesExpired(t *testing.T) { + fp, prov := newFakeProvider(t, "42") + svc, db := newTestService(t, prov, "test-secret") + + _, err := svc.ExchangeCode(context.Background(), prov.Name, "alice", "http://localhost/oauth-callback", "code", nil) + require.NoError(t, err) + + // Force the stored access token to look already-expired. + require.NoError(t, db.Model(&data.Connection{}). + Where("user_id = ? AND provider = ?", "alice", prov.Name). + Update("access_expires_at", time.Now().Add(-time.Hour)).Error) + + // Next call should trigger a refresh and produce the new token. + fp.nextAccess = func() string { return "access-REFRESHED" } + fp.nextRefresh = func() string { return "refresh-ROTATED" } + tok, err := svc.AccessTokenForUser(context.Background(), prov.Name, "alice") + require.NoError(t, err) + assert.Equal(t, "access-REFRESHED", tok) + assert.GreaterOrEqual(t, fp.tokenHits.Load(), int32(2)) + + // The rotated refresh token must be persisted (encrypted). + var row data.Connection + require.NoError(t, db.Where("user_id = ? AND provider = ?", "alice", prov.Name).First(&row).Error) + refreshed, err := Open(svc.Secret, row.RefreshToken) + require.NoError(t, err) + assert.Equal(t, "refresh-ROTATED", string(refreshed)) +} + +func TestAccessTokenForUserNotConnected(t *testing.T) { + _, prov := newFakeProvider(t, "42") + svc, _ := newTestService(t, prov, "test-secret") + + _, err := svc.AccessTokenForUser(context.Background(), prov.Name, "alice") + assert.ErrorIs(t, err, ErrConnectionNotFound) +} + +func TestProviderDisabledWhenNoCreds(t *testing.T) { + _, prov := newFakeProvider(t, "42") + svc, _ := newTestService(t, prov, "test-secret") + svc.GetCreds = func(string) (string, string) { return "", "" } + + _, err := svc.ExchangeCode(context.Background(), prov.Name, "alice", "http://localhost/oauth-callback", "code", nil) + assert.ErrorIs(t, err, ErrProviderDisabled) +} + +func TestDisconnectRemovesRow(t *testing.T) { + _, prov := newFakeProvider(t, "42") + svc, db := newTestService(t, prov, "test-secret") + + conn, err := svc.ExchangeCode(context.Background(), prov.Name, "alice", "http://localhost/oauth-callback", "code", nil) + require.NoError(t, err) + + require.NoError(t, svc.Disconnect(context.Background(), conn.ID, "alice")) + + var count int64 + require.NoError(t, db.Model(&data.Connection{}).Count(&count).Error) + assert.Equal(t, int64(0), count) + + // Disconnecting again is a not-found, not a server error. + err = svc.Disconnect(context.Background(), conn.ID, "alice") + assert.ErrorIs(t, err, ErrConnectionNotFound) +} + +func TestDisconnectIgnoresOtherUsers(t *testing.T) { + _, prov := newFakeProvider(t, "42") + svc, db := newTestService(t, prov, "test-secret") + require.NoError(t, db.Create(&data.User{Username: "mallory", APIKey: "m"}).Error) + + conn, err := svc.ExchangeCode(context.Background(), prov.Name, "alice", "http://localhost/oauth-callback", "code", nil) + require.NoError(t, err) + + err = svc.Disconnect(context.Background(), conn.ID, "mallory") + assert.ErrorIs(t, err, ErrConnectionNotFound, "users must not be able to delete each other's connections") + + var count int64 + require.NoError(t, db.Model(&data.Connection{}).Count(&count).Error) + assert.Equal(t, int64(1), count) +} + +// Sanity check that the URL helpers still parse cleanly when the provider +// uses an Endpoint with absolute URLs (oauth2.Config requires scheme). +func TestProviderOAuth2Config(t *testing.T) { + _, prov := newFakeProvider(t, "1") + cfg := prov.OAuth2Config("cid", "csec", "https://x.example/oauth-callback", []string{"foo"}) + require.NotNil(t, cfg) + assert.Equal(t, "cid", cfg.ClientID) + assert.Equal(t, []string{"foo"}, cfg.Scopes) + + authURL, err := url.Parse(cfg.AuthCodeURL("state")) + require.NoError(t, err) + assert.Contains(t, authURL.Path, "/authorize") +} diff --git a/internal/connections/spotify.go b/internal/connections/spotify.go new file mode 100644 index 00000000..09356dd0 --- /dev/null +++ b/internal/connections/spotify.go @@ -0,0 +1,75 @@ +package connections + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "golang.org/x/oauth2" +) + +// Spotify returns the Spotify OAuth2 provider definition. Endpoints from +// https://developer.spotify.com/documentation/web-api/tutorials/code-flow. +// Spotify authenticates the token endpoint with an HTTP Basic header, +// refresh responses may omit the refresh_token (the stored one stays +// valid), and access tokens live one hour. +// +// Note for admins: since late 2025 Spotify requires HTTPS redirect URIs +// (plain http is allowed only for literal loopback addresses like +// http://127.0.0.1:8000 — "localhost", LAN IPs, and .local names are +// rejected), and Development Mode apps are capped at 5 allowlisted users +// with a Premium-subscribed app owner. See .env.example. +func Spotify() *Provider { + return &Provider{ + Name: "spotify", + DisplayName: "Spotify", + AuthorizeURL: "https://accounts.spotify.com/authorize", + TokenURL: "https://accounts.spotify.com/api/token", + AuthStyle: oauth2.AuthStyleInHeader, + DefaultScopes: []string{ + "user-read-currently-playing", + "user-read-playback-state", + "user-read-recently-played", + }, + Identify: identifySpotify, + } +} + +// identifySpotify calls /v1/me to capture the user id and display name. +// The basic profile fields need no extra scopes. +func identifySpotify(ctx context.Context, accessToken string) (externalID, displayName string, err error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.spotify.com/v1/me", nil) + if err != nil { + return "", "", err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Accept", "application/json") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", "", err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return "", "", fmt.Errorf("spotify /v1/me returned %d", resp.StatusCode) + } + + var body struct { + ID string `json:"id"` + DisplayName string `json:"display_name"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return "", "", err + } + + externalID = body.ID + displayName = body.DisplayName + if displayName == "" { + displayName = formatExternalID("spotify", externalID) + } + return externalID, displayName, nil +} diff --git a/internal/connections/spotify_test.go b/internal/connections/spotify_test.go new file mode 100644 index 00000000..cf83df22 --- /dev/null +++ b/internal/connections/spotify_test.go @@ -0,0 +1,70 @@ +package connections + +import ( + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "golang.org/x/oauth2" +) + +func TestRegistryMatchesBuiltinProviders(t *testing.T) { + r := NewRegistry(Strava(), Spotify()) + + cases := []struct { + name string + url string + want string // expected provider name; "" means no match + }{ + {"strava", "https://www.strava.com/oauth/authorize", "strava"}, + {"spotify", "https://accounts.spotify.com/authorize", "spotify"}, + {"spotify with query", "https://accounts.spotify.com/authorize?client_id=x", "spotify"}, + {"unknown host", "https://example.com/oauth/authorize", ""}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, ok := r.MatchAuthorizeURL(tc.url) + if tc.want == "" { + assert.False(t, ok) + return + } + assert.True(t, ok) + assert.Equal(t, tc.want, got.Name) + }) + } +} + +// TestStravaScopesAreCommaJoined pins Strava's unusual scope encoding: +// the authorize URL must carry one comma-delimited scope value, not the +// space-joined list the oauth2 library would produce by default. +func TestStravaScopesAreCommaJoined(t *testing.T) { + cfg := Strava().OAuth2Config("id", "secret", "https://tronbyt.example.com/oauth-callback", []string{"read", "activity:read"}) + + authURL, err := url.Parse(cfg.AuthCodeURL("state123")) + if err != nil { + t.Fatalf("parse auth url: %v", err) + } + assert.Equal(t, "read,activity:read", authURL.Query().Get("scope")) + assert.False(t, strings.Contains(authURL.Query().Get("scope"), " "), + "Strava rejects space-delimited scopes encoded as '+'") + + // Strava wants client credentials as POST params, not a Basic header. + assert.Equal(t, oauth2.AuthStyleInParams, cfg.Endpoint.AuthStyle) +} + +func TestSpotifyConfigUsesBasicAuthAndSpaceScopes(t *testing.T) { + prov := Spotify() + cfg := prov.OAuth2Config("id", "secret", "https://tronbyt.example.com/oauth-callback", nil) + + assert.Equal(t, oauth2.AuthStyleInHeader, cfg.Endpoint.AuthStyle) + assert.Equal(t, "https://accounts.spotify.com/api/token", cfg.Endpoint.TokenURL) + + authURL, err := url.Parse(cfg.AuthCodeURL("state123")) + if err != nil { + t.Fatalf("parse auth url: %v", err) + } + // Defaults apply when the schema declares no scopes, space-delimited. + assert.Equal(t, strings.Join(prov.DefaultScopes, " "), authURL.Query().Get("scope")) +} diff --git a/internal/connections/strava.go b/internal/connections/strava.go new file mode 100644 index 00000000..923ff2fd --- /dev/null +++ b/internal/connections/strava.go @@ -0,0 +1,74 @@ +package connections + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" + "time" + + "golang.org/x/oauth2" +) + +// Strava returns the Strava OAuth2 provider definition. Endpoints from +// https://developers.strava.com/docs/authentication/. Strava is a +// confidential-client-only provider (no PKCE, no device flow), wants +// client credentials as POST params, rotates refresh tokens on use, and +// historically required comma-delimited scopes — hence ScopeJoin. +func Strava() *Provider { + return &Provider{ + Name: "strava", + DisplayName: "Strava", + AuthorizeURL: "https://www.strava.com/oauth/authorize", + TokenURL: "https://www.strava.com/oauth/token", + AuthStyle: oauth2.AuthStyleInParams, + ScopeJoin: ",", + DefaultScopes: []string{"read", "activity:read"}, + Identify: identifyStrava, + } +} + +// identifyStrava calls /api/v3/athlete to capture the athlete id and a +// display name. We only need this once at connection time; the renderer +// will use the access token directly afterwards. +func identifyStrava(ctx context.Context, accessToken string) (externalID, displayName string, err error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://www.strava.com/api/v3/athlete", nil) + if err != nil { + return "", "", err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Accept", "application/json") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", "", err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return "", "", fmt.Errorf("strava /athlete returned %d", resp.StatusCode) + } + + var body struct { + ID int64 `json:"id"` + FirstName string `json:"firstname"` + LastName string `json:"lastname"` + Username string `json:"username"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return "", "", err + } + + externalID = strconv.FormatInt(body.ID, 10) + switch { + case body.FirstName != "" || body.LastName != "": + displayName = fmt.Sprintf("%s %s", body.FirstName, body.LastName) + case body.Username != "": + displayName = body.Username + default: + displayName = formatExternalID("strava", externalID) + } + return externalID, displayName, nil +} diff --git a/internal/data/models.go b/internal/data/models.go index 557ae9a7..6eaaa914 100644 --- a/internal/data/models.go +++ b/internal/data/models.go @@ -540,6 +540,24 @@ type User struct { Devices []Device `gorm:"foreignKey:Username;references:Username" json:"devices"` Credentials []WebAuthnCredential `gorm:"foreignKey:UserID;references:Username" json:"credentials"` OIDCIdentities []OIDCIdentity `gorm:"foreignKey:UserID;references:Username" json:"oidc_identities"` + Connections []Connection `gorm:"foreignKey:UserID;references:Username" json:"connections"` +} + +// Connection links a user to a third-party OAuth2 provider (e.g. Strava). +// Tokens are stored encrypted with a key derived from the server's secret_key. +type Connection struct { + ID uint `gorm:"primaryKey"` + UserID string `gorm:"uniqueIndex:idx_user_provider,priority:1;not null"` + User User `gorm:"foreignKey:UserID;references:Username" json:"-"` + Provider string `gorm:"uniqueIndex:idx_user_provider,priority:2;not null"` // "strava", "google", ... + ExternalID string // Provider-side user id (e.g. Strava athlete.id) + DisplayName string // Optional human label shown in UI + Scopes string // Space-separated scopes actually granted + AccessToken []byte `json:"-"` // AES-GCM encrypted + RefreshToken []byte `json:"-"` // AES-GCM encrypted + AccessExpiresAt time.Time + CreatedAt time.Time + UpdatedAt time.Time } type WebAuthnCredential struct { diff --git a/internal/migration/migration.go b/internal/migration/migration.go index cbe4c58c..7634f1aa 100644 --- a/internal/migration/migration.go +++ b/internal/migration/migration.go @@ -55,7 +55,7 @@ func MigrateLegacyDB(oldDBPath, newDBLocation, dataDir string) error { } // AutoMigrate schema - err = newDB.AutoMigrate(&data.User{}, &data.Device{}, &data.App{}, &data.WebAuthnCredential{}, &data.Setting{}) + err = newDB.AutoMigrate(&data.User{}, &data.Device{}, &data.App{}, &data.WebAuthnCredential{}, &data.Setting{}, &data.Connection{}) if err != nil { return fmt.Errorf("failed to migrate schema: %w", err) } diff --git a/internal/server/auth.go b/internal/server/auth.go index afc8eeda..0bb94d32 100644 --- a/internal/server/auth.go +++ b/internal/server/auth.go @@ -522,4 +522,14 @@ func (s *Server) SetupAuthRoutes() { s.Router.HandleFunc("GET /auth/oidc/link", s.RequireLogin(http.HandlerFunc(s.handleOIDCLink)).ServeHTTP) s.Router.HandleFunc("POST /auth/oidc/unlink/{id}", s.RequireLogin(http.HandlerFunc(s.handleOIDCUnlink)).ServeHTTP) s.Router.HandleFunc("GET /auth/oidc/callback", s.handleOIDCCallback) + + // Third-party OAuth2 connections (Strava, etc.). All require an + // authenticated session — connections are owned by users. + s.Router.HandleFunc("GET /connections", s.RequireLogin(s.handleConnectionsPage)) + s.Router.HandleFunc("GET /connections/start/{provider}", s.RequireLogin(s.handleConnectionStart)) + // Device authorization grant — no redirect URI, code shown on the display. + s.Router.HandleFunc("GET /connections/device/{provider}", s.RequireLogin(s.handleDeviceFlowStart)) + s.Router.HandleFunc("GET /connections/device/status/{id}", s.RequireLogin(s.handleDeviceFlowStatus)) + s.Router.HandleFunc("GET /oauth-callback", s.RequireLogin(s.handleConnectionCallback)) + s.Router.HandleFunc("POST /connections/{id}/disconnect", s.RequireLogin(s.handleConnectionDisconnect)) } diff --git a/internal/server/connections_cache_test.go b/internal/server/connections_cache_test.go new file mode 100644 index 00000000..73639c87 --- /dev/null +++ b/internal/server/connections_cache_test.go @@ -0,0 +1,123 @@ +package server + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// writeStarApp writes a minimal pixlet app whose schema declares one +// OAuth2 field pointing at authorizeURL, and returns its path. +func writeStarApp(t *testing.T, dir, authorizeURL string) string { + t.Helper() + src := fmt.Sprintf(` +load("render.star", "render") +load("schema.star", "schema") + +def main(config): + return render.Root(child = render.Text("hi")) + +def oauth_handler(params): + return "" + +def get_schema(): + return schema.Schema( + version = "1", + fields = [ + schema.OAuth2( + id = "auth", + name = "Test Provider", + desc = "Connect your account", + icon = "link", + handler = oauth_handler, + client_id = "unused", + authorization_endpoint = "%s", + scopes = ["read"], + ), + ], + ) +`, authorizeURL) + + path := filepath.Join(dir, "app.star") + require.NoError(t, os.WriteFile(path, []byte(src), 0o644)) + return path +} + +// plainStarApp writes an app with no OAuth2 field — the common case that +// must be cached negatively so renders don't re-evaluate starlark. +func plainStarApp(t *testing.T, path string) { + t.Helper() + src := ` +load("render.star", "render") + +def main(config): + return render.Root(child = render.Text("plain")) +` + require.NoError(t, os.WriteFile(path, []byte(src), 0o644)) +} + +func TestOAuth2FieldsForAppCachesAndInvalidates(t *testing.T) { + s := newTestServer(t) + path := writeStarApp(t, t.TempDir(), "https://www.strava.com/oauth/authorize") + + first := s.oauth2FieldsForApp(context.Background(), path) + require.Len(t, first, 1) + assert.Equal(t, "auth", first[0].ID) + assert.Equal(t, "https://www.strava.com/oauth/authorize", first[0].AuthorizationEndpoint) + + // A second call must hit the cache: same backing array, no re-evaluation. + second := s.oauth2FieldsForApp(context.Background(), path) + require.Len(t, second, 1) + assert.Same(t, &first[0], &second[0], "expected the cached slice, not a fresh parse") + + // Rewriting the app invalidates the entry (mtime and size both change). + plainStarApp(t, path) + assert.Empty(t, s.oauth2FieldsForApp(context.Background(), path), + "cache must invalidate when the app file changes") +} + +func TestOAuth2FieldsForAppMissingFile(t *testing.T) { + s := newTestServer(t) + assert.Nil(t, s.oauth2FieldsForApp(context.Background(), filepath.Join(t.TempDir(), "nope.star"))) +} + +// TestInjectConnectionTokensEndToEnd walks the whole path: a connected +// user, an app declaring an OAuth2 field for that provider, and the +// resulting render config carrying a live access token. +func TestInjectConnectionTokensEndToEnd(t *testing.T) { + s, fp, cookie := makeServerWithFakeProvider(t) + _ = cookie + + path := writeStarApp(t, t.TempDir(), fp.server.URL+"/authorize") + + // Connect alice to the fake provider. + _, err := s.Connections.ExchangeCode( + context.Background(), "fake", "alice", + "http://localhost/oauth-callback", "auth-code", []string{"read"}, + ) + require.NoError(t, err) + + config := map[string]any{} + injected := s.injectConnectionTokens(context.Background(), config, path, "alice") + + assert.True(t, injected["auth"], "the oauth2 field should be reported as injected") + assert.Equal(t, "fake-access-token", config["auth"], + "apps receive a plain access-token string, never the refresh token") +} + +func TestInjectConnectionTokensSkipsUnconnectedUser(t *testing.T) { + s, fp, _ := makeServerWithFakeProvider(t) + path := writeStarApp(t, t.TempDir(), fp.server.URL+"/authorize") + + config := map[string]any{} + injected := s.injectConnectionTokens(context.Background(), config, path, "alice") + + assert.Empty(t, injected) + assert.NotContains(t, config, "auth", + "an unconnected user leaves the field absent so the app can prompt") +} diff --git a/internal/server/connections_inject.go b/internal/server/connections_inject.go new file mode 100644 index 00000000..7b565c6e --- /dev/null +++ b/internal/server/connections_inject.go @@ -0,0 +1,291 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "os" + "strings" + + "tronbyt-server/internal/connections" + "tronbyt-server/internal/data" + "tronbyt-server/internal/renderer" + + "gorm.io/gorm" +) + +// oauth2Field is the subset of a pixlet schema.OAuth2 field we care about +// at render time. We deliberately only decode known keys so changes +// upstream don't break parsing. +type oauth2Field struct { + Type string `json:"type"` + ID string `json:"id"` + AuthorizationEndpoint string `json:"authorization_endpoint"` +} + +// schemaOAuth2Fields parses a schema JSON blob and returns the OAuth2 +// fields. Returns nil for any malformed schema; callers treat that as "no +// fields to inject" rather than a render error. +func schemaOAuth2Fields(schemaJSON []byte) []oauth2Field { + if len(schemaJSON) == 0 { + return nil + } + var doc struct { + Schema []json.RawMessage `json:"schema"` + } + if err := json.Unmarshal(schemaJSON, &doc); err != nil { + return nil + } + var out []oauth2Field + for _, raw := range doc.Schema { + var f oauth2Field + if err := json.Unmarshal(raw, &f); err != nil { + continue + } + if f.Type == "oauth2" && f.ID != "" { + out = append(out, f) + } + } + return out +} + +// oauth2FieldsEntry memoizes the oauth2 fields extracted from one version +// of an app. Extracting them requires a full starlark evaluation +// (renderer.GetSchema loads and executes the applet), which is far too +// expensive to repeat on every render on Pi-class hardware — so results +// are cached and revalidated against a cheap fingerprint of the app's +// source files. Negative results (no oauth2 fields — the overwhelmingly +// common case) are cached too. +type oauth2FieldsEntry struct { + fingerprint string + fields []oauth2Field +} + +// appFingerprint identifies the current content of an app cheaply enough +// to check on every render. Single-file apps use mtime+size; directory +// apps — which is every app from the system repo — fold in each source +// file, so editing or adding a .star invalidates the entry. A directory's +// own mtime is not enough: it doesn't change when a file inside is +// rewritten in place. +func appFingerprint(appPath string) (string, error) { + info, err := os.Stat(appPath) + if err != nil { + return "", err + } + if !info.IsDir() { + return fmt.Sprintf("f:%d:%d", info.ModTime().UnixNano(), info.Size()), nil + } + + entries, err := os.ReadDir(appPath) // sorted by filename + if err != nil { + return "", err + } + var b strings.Builder + b.WriteString("d:") + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if !strings.HasSuffix(name, ".star") && name != "manifest.yaml" { + continue + } + fi, err := entry.Info() + if err != nil { + return "", err + } + fmt.Fprintf(&b, "%s:%d:%d;", name, fi.ModTime().UnixNano(), fi.Size()) + } + return b.String(), nil +} + +// oauth2FieldsForApp returns the oauth2 fields declared by the app at +// appPath, re-evaluating the schema only when the app's sources change. +func (s *Server) oauth2FieldsForApp(ctx context.Context, appPath string) []oauth2Field { + fingerprint, err := appFingerprint(appPath) + if err != nil { + return nil + } + + s.oauth2FieldsMu.RLock() + e, ok := s.oauth2FieldsCache[appPath] + s.oauth2FieldsMu.RUnlock() + if ok && e.fingerprint == fingerprint { + return e.fields + } + + schemaJSON, err := renderer.GetSchema(ctx, appPath, 64, 32, false) + if err != nil { + // Don't cache failures. A transient error — a canceled request + // context, or a get_schema() that fetches over the network — would + // otherwise be pinned as "this app has no oauth2 fields" for the + // life of the process, silently dropping token injection while the + // UI still reports the account as connected. + slog.Warn("Schema evaluation failed; skipping connection token injection", + "path", appPath, "error", err) + return nil + } + fields := schemaOAuth2Fields(schemaJSON) + + s.oauth2FieldsMu.Lock() + if s.oauth2FieldsCache == nil { + s.oauth2FieldsCache = make(map[string]oauth2FieldsEntry) + } + s.oauth2FieldsCache[appPath] = oauth2FieldsEntry{ + fingerprint: fingerprint, + fields: fields, + } + s.oauth2FieldsMu.Unlock() + return fields +} + +// injectConnectionTokens augments the render config with fresh access +// tokens for any third-party provider declared in the app's schema. It is +// best-effort: if a provider isn't recognised, isn't configured, or the +// user hasn't connected it, we leave the field absent so the starlark app +// can render its own "connect first" prompt. +// +// appPath is the resolved star file path; username is the owner. The +// returned set holds the field IDs that actually received a token — +// handleSchemaHandler uses it to route tokens into one-param handlers. +func (s *Server) injectConnectionTokens(ctx context.Context, config map[string]any, appPath, username string) map[string]bool { + // A nil config is a legitimate input on the schema-handler path (the + // request body may omit "config" entirely), and writing to a nil map + // panics. + if s.Connections == nil || s.ConnectionsRegistry == nil || config == nil || appPath == "" || username == "" { + return nil + } + + fields := s.oauth2FieldsForApp(ctx, appPath) + if len(fields) == 0 { + return nil + } + + injected := make(map[string]bool, len(fields)) + for _, f := range fields { + provider, ok := s.ConnectionsRegistry.MatchAuthorizeURL(f.AuthorizationEndpoint) + if !ok { + continue + } + token, err := s.Connections.AccessTokenForUser(ctx, provider.Name, username) + if err != nil { + if !errors.Is(err, connections.ErrConnectionNotFound) { + slog.Warn("Connection token unavailable for render", "provider", provider.Name, "user", username, "error", err) + } + continue + } + // Pixlet's runtime stringifies config values before they reach + // starlark, so we hand back a plain access token string. Apps + // read config[field.id] as a string (the bearer token); they + // never see the refresh token. Provider-side identifiers like + // the Strava athlete id are derivable from the token itself. + config[f.ID] = token + injected[f.ID] = true + } + return injected +} + +// annotateSchemaForUI rewrites the schema JSON before it's sent to the +// browser so the config form can render a useful "Connect" button for each +// OAuth2 field. We strip the bake-time client_id (the server controls +// credentials) and add tronbyt-specific hints: +// - tronbyt_provider: resolved provider name, if known +// - tronbyt_connected: bool — does the user have a live connection? +// - tronbyt_label: display name from the connection (e.g. athlete name) +// - tronbyt_configured: bool — has the admin set the env-var credentials? +// - tronbyt_scopes: space-separated scopes to request +// +// The JS in configapp.html keys off these fields. Apps and pixlet itself +// continue to see the original shape — these extras are additive. +func (s *Server) annotateSchemaForUI(ctx context.Context, schemaJSON []byte, username string) []byte { + if s.ConnectionsRegistry == nil || len(schemaJSON) == 0 { + return schemaJSON + } + + var doc map[string]any + if err := json.Unmarshal(schemaJSON, &doc); err != nil { + return schemaJSON + } + rawFields, ok := doc["schema"].([]any) + if !ok { + return schemaJSON + } + + mutated := false + for _, raw := range rawFields { + field, ok := raw.(map[string]any) + if !ok { + continue + } + typ, _ := field["type"].(string) + if typ != "oauth2" { + continue + } + + mutated = true + fieldID, _ := field["id"].(string) + authzURL, _ := field["authorization_endpoint"].(string) + + provider, providerOK := s.ConnectionsRegistry.MatchAuthorizeURL(authzURL) + if !providerOK { + field["tronbyt_provider"] = "" + field["tronbyt_configured"] = false + field["tronbyt_connected"] = false + continue + } + + // A provider counts as configured if *either* flow can run. The + // device flow needs no secret, so GitHub is usable with just a + // client id — the button then points at the code flow instead of + // a redirect. + deviceFlow := s.Connections.DeviceFlowAvailable(provider) + field["tronbyt_provider"] = provider.Name + field["tronbyt_configured"] = s.Connections.CodeFlowAvailable(provider) || deviceFlow + field["tronbyt_device_flow"] = deviceFlow + field["tronbyt_display_name"] = provider.DisplayName + + // Don't leak the bake-time client_id from the app's schema; the + // server uses its own. The auth flow happens server-side anyway. + field["client_id"] = "" + + // Pass scopes through as a string the JS can forward. + switch sc := field["scopes"].(type) { + case []any: + parts := make([]string, 0, len(sc)) + for _, v := range sc { + if s, ok := v.(string); ok { + parts = append(parts, s) + } + } + field["tronbyt_scopes"] = strings.Join(parts, " ") + case string: + field["tronbyt_scopes"] = sc + default: + field["tronbyt_scopes"] = "" + } + + // Look up the user's live connection (if any) for connection state. + conn, err := gorm.G[data.Connection](s.DB). + Where("user_id = ? AND provider = ?", username, provider.Name). + First(ctx) + if err == nil { + field["tronbyt_connected"] = true + field["tronbyt_label"] = conn.DisplayName + field["tronbyt_connection_id"] = conn.ID + } else { + field["tronbyt_connected"] = false + } + _ = fieldID + } + + if !mutated { + return schemaJSON + } + out, err := json.Marshal(doc) + if err != nil { + return schemaJSON + } + return out +} diff --git a/internal/server/connections_inject_test.go b/internal/server/connections_inject_test.go new file mode 100644 index 00000000..f1a6fc35 --- /dev/null +++ b/internal/server/connections_inject_test.go @@ -0,0 +1,198 @@ +package server + +import ( + "context" + "encoding/json" + "testing" + + "tronbyt-server/internal/config" + "tronbyt-server/internal/connections" + "tronbyt-server/internal/data" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestSchemaOAuth2Fields(t *testing.T) { + cases := []struct { + name string + in string + want []string // expected field IDs in order + }{ + { + name: "single oauth2 field", + in: `{"version":"1","schema":[ + {"type":"oauth2","id":"strava","authorization_endpoint":"https://www.strava.com/oauth/authorize"} + ]}`, + want: []string{"strava"}, + }, + { + name: "mixed schema, oauth2 plus text", + in: `{"schema":[ + {"type":"text","id":"unit"}, + {"type":"oauth2","id":"auth","authorization_endpoint":"https://accounts.spotify.com/authorize"}, + {"type":"dropdown","id":"sport"} + ]}`, + want: []string{"auth"}, + }, + { + name: "no oauth2 fields", + in: `{"schema":[{"type":"text","id":"x"}]}`, + want: nil, + }, + { + name: "garbage input returns nil", + in: `not json`, + want: nil, + }, + { + name: "empty schema array", + in: `{"schema":[]}`, + want: nil, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := schemaOAuth2Fields([]byte(tc.in)) + ids := make([]string, len(got)) + for i, f := range got { + ids[i] = f.ID + } + if tc.want == nil { + assert.Empty(t, ids) + return + } + assert.Equal(t, tc.want, ids) + }) + } +} + +// makeAnnotateServer builds the minimal *Server needed to exercise +// annotateSchemaForUI: DB, registry, config. No HTTP, no templates. +func makeAnnotateServer(t *testing.T, cfg *config.Settings) *Server { + t.Helper() + db, err := gorm.Open(sqlite.Open("file::memory:?cache=private"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&data.User{}, &data.Connection{})) + + s := &Server{ + DB: db, + Config: cfg, + ConnectionsRegistry: connections.NewRegistry(connections.Strava()), + } + s.Connections = &connections.Service{ + DB: db, + Registry: s.ConnectionsRegistry, + Secret: "test-secret", + GetCreds: cfg.ConnectionClientCreds, + } + return s +} + +func TestAnnotateSchemaForUI_UnconfiguredProvider(t *testing.T) { + s := makeAnnotateServer(t, &config.Settings{}) // no STRAVA_* set + + in := []byte(`{"version":"1","schema":[ + {"type":"oauth2","id":"strava","name":"Strava Login", + "client_id":"baked-in-id-from-app", + "authorization_endpoint":"https://www.strava.com/oauth/authorize", + "scopes":["read","activity:read"]} + ]}`) + + out := s.annotateSchemaForUI(context.Background(), in, "alice") + + field := firstSchemaField(t, out) + assert.Equal(t, "strava", field["tronbyt_provider"]) + assert.Equal(t, false, field["tronbyt_configured"]) + assert.Equal(t, false, field["tronbyt_connected"]) + assert.Equal(t, "Strava", field["tronbyt_display_name"]) + assert.Equal(t, "read activity:read", field["tronbyt_scopes"]) + // The bake-time client_id must be wiped — server uses its own. + assert.Equal(t, "", field["client_id"]) +} + +func TestAnnotateSchemaForUI_ConfiguredButNotConnected(t *testing.T) { + cfg := &config.Settings{ + StravaClientID: "real-id", + StravaClientSecret: "real-secret", + } + s := makeAnnotateServer(t, cfg) + require.NoError(t, s.DB.Create(&data.User{Username: "alice", APIKey: "k"}).Error) + + in := []byte(`{"schema":[ + {"type":"oauth2","id":"strava","authorization_endpoint":"https://www.strava.com/oauth/authorize"} + ]}`) + out := s.annotateSchemaForUI(context.Background(), in, "alice") + + field := firstSchemaField(t, out) + assert.Equal(t, true, field["tronbyt_configured"]) + assert.Equal(t, false, field["tronbyt_connected"]) +} + +func TestAnnotateSchemaForUI_Connected(t *testing.T) { + cfg := &config.Settings{ + StravaClientID: "real-id", + StravaClientSecret: "real-secret", + } + s := makeAnnotateServer(t, cfg) + require.NoError(t, s.DB.Create(&data.User{Username: "alice", APIKey: "k"}).Error) + + // Pretend alice has already connected Strava. + require.NoError(t, s.DB.Create(&data.Connection{ + UserID: "alice", + Provider: "strava", + ExternalID: "12345", + DisplayName: "Alice Athlete", + }).Error) + + in := []byte(`{"schema":[ + {"type":"oauth2","id":"strava","authorization_endpoint":"https://www.strava.com/oauth/authorize"} + ]}`) + out := s.annotateSchemaForUI(context.Background(), in, "alice") + + field := firstSchemaField(t, out) + assert.Equal(t, true, field["tronbyt_connected"]) + assert.Equal(t, "Alice Athlete", field["tronbyt_label"]) + // connection_id is round-tripped via JSON, so it'll come back as float64. + id, ok := field["tronbyt_connection_id"].(float64) + assert.True(t, ok) + assert.Greater(t, id, 0.0) +} + +func TestAnnotateSchemaForUI_UnknownProvider(t *testing.T) { + s := makeAnnotateServer(t, &config.Settings{}) + + in := []byte(`{"schema":[ + {"type":"oauth2","id":"weirdo","authorization_endpoint":"https://example.com/oauth/authorize"} + ]}`) + out := s.annotateSchemaForUI(context.Background(), in, "alice") + + field := firstSchemaField(t, out) + assert.Equal(t, "", field["tronbyt_provider"]) + assert.Equal(t, false, field["tronbyt_configured"]) + assert.Equal(t, false, field["tronbyt_connected"]) +} + +func TestAnnotateSchemaForUI_NoOAuth2FieldsLeavesSchemaAlone(t *testing.T) { + s := makeAnnotateServer(t, &config.Settings{}) + + in := []byte(`{"schema":[{"type":"text","id":"hello"}]}`) + out := s.annotateSchemaForUI(context.Background(), in, "alice") + + // Byte-identical when there's nothing to annotate; cheaper for the + // common case and makes diffs easier to read. + assert.Equal(t, string(in), string(out)) +} + +// firstSchemaField extracts schema[0] as a generic map for assertions. +func firstSchemaField(t *testing.T, raw []byte) map[string]any { + t.Helper() + var doc struct { + Schema []map[string]any `json:"schema"` + } + require.NoError(t, json.Unmarshal(raw, &doc)) + require.NotEmpty(t, doc.Schema) + return doc.Schema[0] +} diff --git a/internal/server/connections_page_test.go b/internal/server/connections_page_test.go new file mode 100644 index 00000000..482ea08f --- /dev/null +++ b/internal/server/connections_page_test.go @@ -0,0 +1,97 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "testing" + + "tronbyt-server/internal/data" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestIsSafeReturnTo pins the open-redirect guard. Backslashes matter: +// url.Parse treats "\" as an ordinary path byte, but browsers normalize +// "/\evil.com" into the protocol-relative "//evil.com". +func TestIsSafeReturnTo(t *testing.T) { + cases := []struct { + in string + want bool + }{ + {"/", true}, + {"/devices/abc/123/config", true}, + {"/connections?tab=1", true}, + {"", false}, + {"https://evil.com", false}, + {"//evil.com", false}, + {"http://evil.com/path", false}, + {"evil.com", false}, + {`/\evil.com`, false}, + {`/\/evil.com`, false}, + {`\\evil.com`, false}, + {`/path\..\parent`, false}, + } + + for _, tc := range cases { + t.Run(tc.in, func(t *testing.T) { + assert.Equal(t, tc.want, isSafeReturnTo(tc.in)) + }) + } +} + +func TestConnectionsPageShowsConfiguredProviders(t *testing.T) { + s, _, cookie := makeServerWithFakeProvider(t) + + req := httptest.NewRequest(http.MethodGet, "/connections", nil) + req.Header.Set("Cookie", cookie) + rr := httptest.NewRecorder() + s.ServeHTTP(rr, req) + + require.Equal(t, http.StatusOK, rr.Code) + body := rr.Body.String() + assert.Contains(t, body, "Fake", "configured provider should be listed") + assert.Contains(t, body, "/connections/start/fake", "should offer a Connect link") + assert.NotContains(t, body, "Connected as") +} + +func TestConnectionsPageShowsConnectedState(t *testing.T) { + s, _, cookie := makeServerWithFakeProvider(t) + + conn := data.Connection{ + UserID: "alice", + Provider: "fake", + ExternalID: "12345", + DisplayName: "Test Athlete", + Scopes: "read", + } + require.NoError(t, s.DB.Create(&conn).Error) + + req := httptest.NewRequest(http.MethodGet, "/connections", nil) + req.Header.Set("Cookie", cookie) + rr := httptest.NewRecorder() + s.ServeHTTP(rr, req) + + require.Equal(t, http.StatusOK, rr.Code) + body := rr.Body.String() + assert.Contains(t, body, "Connected as") + assert.Contains(t, body, "Test Athlete") + assert.Contains(t, body, "/connections/1/disconnect") +} + +// TestConnectionsPageHidesUnconfiguredProviders keeps providers the admin +// hasn't supplied credentials for off the page (and out of the nav). +func TestConnectionsPageHidesUnconfiguredProviders(t *testing.T) { + s, _, cookie := makeServerWithFakeProvider(t) + s.Connections.GetCreds = func(string) (string, string) { return "", "" } + + assert.False(t, s.anyConnectionProviderConfigured()) + + req := httptest.NewRequest(http.MethodGet, "/connections", nil) + req.Header.Set("Cookie", cookie) + rr := httptest.NewRecorder() + s.ServeHTTP(rr, req) + + require.Equal(t, http.StatusOK, rr.Code) + assert.NotContains(t, rr.Body.String(), "/connections/start/fake") +} diff --git a/internal/server/device_code_image.go b/internal/server/device_code_image.go new file mode 100644 index 00000000..d52ecdef --- /dev/null +++ b/internal/server/device_code_image.go @@ -0,0 +1,189 @@ +package server + +import ( + "context" + "image/color" + "log/slog" + "os" + "path/filepath" + "strings" + "time" + + "tronbyt-server/internal/data" + + securejoin "github.com/cyphar/filepath-securejoin" + "github.com/tronbyt/pixlet/encode" + "github.com/tronbyt/pixlet/render" +) + +// Device-code display: while a device-authorization flow is pending we +// render the user code as a WebP and push it to the user's displays, so +// the code shows up on the matrix itself — read it off the shelf, approve +// on your phone. The image is pushed as an *ephemeral* frame (a "__" +// prefixed file), which GetNextAppImage serves once and deletes. + +// deviceCodeImageName is a fixed ephemeral filename so repeated pushes +// overwrite one another instead of queueing a backlog of stale codes. +// The "__" prefix marks it ephemeral for GetNextAppImage. +const deviceCodeImageName = "__device_code.webp" + +// Colors chosen for legibility on an LED matrix: amber label, white code. +var ( + deviceCodeLabelColor = color.RGBA{R: 0xff, G: 0xa5, B: 0x00, A: 0xff} + deviceCodeCodeColor = color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff} + deviceCodeURLColor = color.RGBA{R: 0x88, G: 0xcc, B: 0xff, A: 0xff} +) + +// renderDeviceCodeImage draws " / / " at the given size and encodes it as an animated WebP (the URL +// line scrolls when it doesn't fit). Sizes follow the render pipeline: +// 64x32, or 128x64 for wide devices. +func renderDeviceCodeImage(ctx context.Context, width, height int, providerName, userCode, verificationURI string) ([]byte, error) { + labelFonts := []string{"tom-thumb"} + codeFonts := []string{"6x13", "5x8", "tom-thumb"} + urlFont := "tom-thumb" + if width >= 128 { + labelFonts = []string{"6x13", "tom-thumb"} + codeFonts = []string{"10x20", "6x13", "5x8"} + urlFont = "6x13" + } + + label, err := fitText(strings.ToUpper(providerName), labelFonts, width, deviceCodeLabelColor) + if err != nil { + return nil, err + } + + // The code is the one thing that must never be clipped — step down + // through narrower fonts until it fits the panel. + code, err := fitText(userCode, codeFonts, width, deviceCodeCodeColor) + if err != nil { + return nil, err + } + + url, err := initText(&render.Text{ + Content: trimURLScheme(verificationURI), + Font: urlFont, + Color: deviceCodeURLColor, + }) + if err != nil { + return nil, err + } + + root := render.Root{ + Delay: 50, + Child: &render.Box{ + Width: width, + Height: height, + Child: &render.Column{ + MainAlign: "space_evenly", + CrossAlign: "center", + Children: []render.Widget{ + label, + code, + // The verification URL rarely fits; Marquee scrolls it + // when needed and centers it when it does fit. + &render.Marquee{Width: width, Align: "center", Child: url}, + }, + }, + }, + } + + screens := encode.ScreensFromRoots([]render.Root{root}, width, height) + return screens.EncodeWebP(ctx, 15*time.Second) +} + +// initText initializes a Text widget's glyph image. Font is always set +// explicitly here, so the nil starlark thread is never dereferenced (it +// is only consulted to pick a default font). +func initText(t *render.Text) (*render.Text, error) { + if err := t.Init(nil); err != nil { + return nil, err + } + return t, nil +} + +// fitText renders content in the first font of the ladder that fits +// maxWidth, falling back to the narrowest if none do. +func fitText(content string, fonts []string, maxWidth int, c color.Color) (*render.Text, error) { + var last *render.Text + for _, font := range fonts { + t, err := initText(&render.Text{Content: content, Font: font, Color: c}) + if err != nil { + return nil, err + } + w, _ := t.Size() + if w <= maxWidth { + return t, nil + } + last = t + } + return last, nil +} + +// trimURLScheme drops "https://" so more of the URL fits on the panel. +func trimURLScheme(u string) string { + u = strings.TrimPrefix(u, "https://") + u = strings.TrimPrefix(u, "http://") + return strings.TrimSuffix(u, "/") +} + +// pushDeviceCodeToDisplays renders the code and pushes it to every device +// the user owns. Best-effort: a failure to reach one display must not +// break the browser flow, since the page shows the same code. +func (s *Server) pushDeviceCodeToDisplays(ctx context.Context, user *data.User, providerName, userCode, verificationURI string) { + for _, device := range user.Devices { + width, height := 64, 32 + if device.Type.Supports2x() { + width, height = 128, 64 + } + + img, err := renderDeviceCodeImage(ctx, width, height, providerName, userCode, verificationURI) + if err != nil { + slog.Warn("Device code: render failed", "device", device.ID, "error", err) + continue + } + + // Websocket-connected devices show it immediately; everything else + // picks it up on its next poll. + s.Broadcaster.Notify(device.ID, img) + if err := s.writeEphemeralPush(device.ID, img); err != nil { + slog.Warn("Device code: push failed", "device", device.ID, "error", err) + } + } +} + +// clearDeviceCodeFromDisplays removes a pending code frame once the flow +// finishes, so an approved (or expired) code doesn't surface later. +func (s *Server) clearDeviceCodeFromDisplays(user *data.User) { + for _, device := range user.Devices { + path, err := s.deviceCodeImagePath(device.ID) + if err != nil { + continue + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + slog.Warn("Device code: clear failed", "device", device.ID, "error", err) + } + } +} + +// deviceCodeImagePath resolves the ephemeral push path for a device. +func (s *Server) deviceCodeImagePath(deviceID string) (string, error) { + dir, err := s.ensureDeviceImageDir(deviceID) + if err != nil { + return "", err + } + return securejoin.SecureJoin(filepath.Join(dir, "pushed"), deviceCodeImageName) +} + +// writeEphemeralPush writes the code frame under a stable ephemeral name, +// replacing any previous one for this device. +func (s *Server) writeEphemeralPush(deviceID string, img []byte) error { + path, err := s.deviceCodeImagePath(deviceID) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + return os.WriteFile(path, img, 0644) +} diff --git a/internal/server/device_code_image_test.go b/internal/server/device_code_image_test.go new file mode 100644 index 00000000..b8d43915 --- /dev/null +++ b/internal/server/device_code_image_test.go @@ -0,0 +1,51 @@ +package server + +import ( + "bytes" + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/image/webp" +) + +func TestRenderDeviceCodeImage(t *testing.T) { + cases := []struct { + name string + width, height int + }{ + {"standard 64x32", 64, 32}, + {"wide 128x64", 128, 64}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + img, err := renderDeviceCodeImage(context.Background(), tc.width, tc.height, + "github", "WDJB-MJHT", "https://github.com/login/device") + require.NoError(t, err) + require.NotEmpty(t, img) + + // It must be a real WebP the device can decode, at the size asked for. + cfg, err := webp.DecodeConfig(bytes.NewReader(img)) + require.NoError(t, err, "device code image must be decodable WebP") + assert.Equal(t, tc.width, cfg.Width) + assert.Equal(t, tc.height, cfg.Height) + }) + } +} + +// TestRenderDeviceCodeImageLongCode guards against a code or URL that +// overflows the panel breaking the render outright. +func TestRenderDeviceCodeImageLongCode(t *testing.T) { + img, err := renderDeviceCodeImage(context.Background(), 64, 32, + "microsoft", "ABCDEFGHIJKLMNOP", "https://microsoft.com/devicelogin") + require.NoError(t, err) + assert.NotEmpty(t, img) +} + +func TestTrimURLScheme(t *testing.T) { + assert.Equal(t, "github.com/login/device", trimURLScheme("https://github.com/login/device")) + assert.Equal(t, "trakt.tv/activate", trimURLScheme("http://trakt.tv/activate/")) + assert.Equal(t, "example.com", trimURLScheme("example.com")) +} diff --git a/internal/server/handlers_api_test.go b/internal/server/handlers_api_test.go index 110cd16e..2a5ce2b0 100644 --- a/internal/server/handlers_api_test.go +++ b/internal/server/handlers_api_test.go @@ -46,7 +46,7 @@ func newTestServerAPI(t *testing.T) *Server { } }) - if err := db.AutoMigrate(&data.User{}, &data.Device{}, &data.App{}, &data.WebAuthnCredential{}, &data.Setting{}); err != nil { + if err := db.AutoMigrate(&data.User{}, &data.Device{}, &data.App{}, &data.WebAuthnCredential{}, &data.Setting{}, &data.Connection{}); err != nil { t.Fatalf("Failed to migrate DB: %v", err) } diff --git a/internal/server/handlers_app.go b/internal/server/handlers_app.go index 41850d40..2c54cf00 100644 --- a/internal/server/handlers_app.go +++ b/internal/server/handlers_app.go @@ -364,6 +364,10 @@ func (s *Server) handleConfigAppGet(w http.ResponseWriter, r *http.Request) { schemaBytes = []byte("{}") } + // Annotate OAuth2 fields with provider state so the form can render + // the right Connect/Connected button without leaking client_ids. + schemaBytes = s.annotateSchemaForUI(r.Context(), schemaBytes, user.Username) + deleteOnCancel := r.URL.Query().Get("delete_on_cancel") == "true" var appMetadata *apps.AppMetadata @@ -568,6 +572,7 @@ func (s *Server) handleSchemaHandler(w http.ResponseWriter, r *http.Request) { // Parse Body var payload struct { Param string `json:"param"` + Source string `json:"source"` Config map[string]any `json:"config"` } @@ -588,6 +593,21 @@ func (s *Server) handleSchemaHandler(w http.ResponseWriter, r *http.Request) { return } + // Schema handlers back generated/typeahead fields, and the canonical + // OAuth pattern is schema.Generated(source = ), whose + // handler expects the token as its parameter. Inject connection tokens + // into the config, and when the source names an oauth2 field (the + // browser sends no value for those) pass the injected token as param. + // Gating on the injected set means a client-supplied source can only + // select a token the same user could already read via config. + injected := s.injectConnectionTokens(r.Context(), payload.Config, appPath, device.Username) + param := payload.Param + if param == "" && injected[payload.Source] { + if tok, ok := payload.Config[payload.Source].(string); ok { + param = tok + } + } + // Call Handler result, err := renderer.CallSchemaHandler( r.Context(), @@ -596,7 +616,7 @@ func (s *Server) handleSchemaHandler(w http.ResponseWriter, r *http.Request) { 64, 32, device.Type.Supports2x(), handler, - payload.Param) + param) if err != nil { slog.Error("Schema handler failed", "handler", handler, "error", err) http.Error(w, "Schema handler failed", http.StatusInternalServerError) diff --git a/internal/server/handlers_connections.go b/internal/server/handlers_connections.go new file mode 100644 index 00000000..e2895a83 --- /dev/null +++ b/internal/server/handlers_connections.go @@ -0,0 +1,344 @@ +package server + +import ( + "encoding/json" + "errors" + "log/slog" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + + "tronbyt-server/internal/connections" + "tronbyt-server/internal/data" +) + +// connectionPendingKey is the session key under which we stash the +// in-flight OAuth state (provider, state token, return URL). One entry at a +// time per session — clicking "Connect" twice abandons the older flow. +const connectionPendingKey = "conn_pending" + +// connectionPending is what we marshal into session for the round-trip. +type connectionPending struct { + State string `json:"state"` + Provider string `json:"provider"` + ReturnTo string `json:"return_to"` + Scopes string `json:"scopes"` // space-separated +} + +// callbackPath is the single stable redirect URI used for all providers. +// Admins must register this exact path with each OAuth provider, e.g. +// https://my-tronbyt.example.com/oauth-callback. +const callbackPath = "/oauth-callback" + +// connectionCallbackURL builds the absolute redirect URI for the current +// request, honouring X-Forwarded-* via ProxyMiddleware. +func (s *Server) connectionCallbackURL(r *http.Request) string { + return strings.TrimRight(s.GetBaseURL(r), "/") + callbackPath +} + +// ConnectionProviderView is one row on the /connections page: a provider +// the admin has configured, plus this user's connection state for it. +type ConnectionProviderView struct { + Name string + DisplayName string + Connected bool + Label string + Scopes string + ConnectionID uint + // DeviceFlow marks providers connected by short code rather than a + // browser redirect — the Connect link points at the device flow and + // the code is shown on the display. + DeviceFlow bool +} + +// anyConnectionProviderConfigured reports whether the admin supplied +// credentials for at least one provider. Drives the nav link. +func (s *Server) anyConnectionProviderConfigured() bool { + if s.Connections == nil || s.ConnectionsRegistry == nil { + return false + } + for _, p := range s.ConnectionsRegistry.All() { + if s.Connections.CodeFlowAvailable(p) || s.Connections.DeviceFlowAvailable(p) { + return true + } + } + return false +} + +// handleConnectionsPage lists the configured providers and the user's +// connection state for each, with Connect/Disconnect controls. +// +// GET /connections +func (s *Server) handleConnectionsPage(w http.ResponseWriter, r *http.Request) { + user := GetUser(r) + + conns, err := s.Connections.ListForUser(r.Context(), user.Username) + if err != nil { + slog.Error("Connections page: list failed", "user", user.Username, "error", err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + byProvider := make(map[string]data.Connection, len(conns)) + for _, c := range conns { + byProvider[c.Provider] = c + } + + var views []ConnectionProviderView + for _, p := range s.ConnectionsRegistry.All() { + codeFlow := s.Connections.CodeFlowAvailable(p) + deviceFlow := s.Connections.DeviceFlowAvailable(p) + if !codeFlow && !deviceFlow { + continue // provider not enabled on this server + } + // Prefer the device flow where it's available: no redirect URI to + // register, and the code can be read straight off the display. + view := ConnectionProviderView{Name: p.Name, DisplayName: p.DisplayName, DeviceFlow: deviceFlow} + if c, ok := byProvider[p.Name]; ok { + view.Connected = true + view.Label = c.DisplayName + if view.Label == "" { + view.Label = c.ExternalID + } + view.Scopes = c.Scopes + view.ConnectionID = c.ID + } + views = append(views, view) + } + sort.Slice(views, func(i, j int) bool { return views[i].DisplayName < views[j].DisplayName }) + + s.renderTemplate(w, r, "connections", TemplateData{ + ConnectionProviders: views, + }) +} + +// handleConnectionStart kicks off the OAuth dance for one provider. It is +// called by the "Connect Strava" button on the app config page or the +// /connections management page. +// +// GET /connections/start/{provider}?return_to=...&scopes=read,activity:read +func (s *Server) handleConnectionStart(w http.ResponseWriter, r *http.Request) { + user := GetUser(r) + providerName := strings.ToLower(r.PathValue("provider")) + + provider, ok := s.ConnectionsRegistry.Get(providerName) + if !ok { + http.Error(w, "Unknown provider", http.StatusNotFound) + return + } + + clientID, clientSecret := s.Connections.GetCreds(provider.Name) + if clientID == "" || clientSecret == "" { + slog.Warn("Connection start: provider not configured", "provider", provider.Name, "user", user.Username) + http.Error(w, "This provider is not configured on the server. Ask your admin to set the "+strings.ToUpper(provider.Name)+"_CLIENT_ID/_SECRET env vars.", http.StatusServiceUnavailable) + return + } + + state, err := generateSecureTokenEncoded(32) + if err != nil { + slog.Error("Failed to generate connection state", "error", err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + + returnTo := r.URL.Query().Get("return_to") + if !isSafeReturnTo(returnTo) { + returnTo = "/" + } + + // Scopes can be passed as comma- or space-separated; normalize to space. + scopes := normalizeScopes(r.URL.Query().Get("scopes")) + if scopes == "" { + // Record what we are actually about to request. Providers that + // don't echo `scope` back in the token response would otherwise + // leave the stored set empty, and nothing downstream could tell + // what the connection is good for. + scopes = strings.Join(provider.DefaultScopes, " ") + } + + pending := connectionPending{ + State: state, + Provider: provider.Name, + ReturnTo: returnTo, + Scopes: scopes, + } + + session, err := s.Store.Get(r, "session-name") + if err != nil { + slog.Error("Connection start: get session", "error", err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + session.Values[connectionPendingKey] = mustEncodePending(pending) + if err := s.saveSession(w, r, session); err != nil { + slog.Error("Connection start: save session", "error", err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + + redirectURL := s.connectionCallbackURL(r) + cfg := provider.OAuth2Config(clientID, clientSecret, redirectURL, splitScopes(scopes)) + + authURL := cfg.AuthCodeURL(state, provider.AuthCodeOptions()...) + http.Redirect(w, r, authURL, http.StatusSeeOther) +} + +// handleConnectionCallback receives the provider's redirect with code+state, +// verifies state against session, exchanges the code, and persists the +// Connection. Single endpoint shared across all providers. +// +// GET /oauth-callback?code=...&state=... +func (s *Server) handleConnectionCallback(w http.ResponseWriter, r *http.Request) { + user := GetUser(r) + q := r.URL.Query() + + if errMsg := q.Get("error"); errMsg != "" { + slog.Warn("Connection callback: provider returned error", "error", errMsg, "desc", q.Get("error_description"), "user", user.Username) + s.flashAndRedirect(w, r, "Could not connect: "+errMsg, "/", http.StatusSeeOther) + return + } + + session, err := s.Store.Get(r, "session-name") + if err != nil { + slog.Error("Connection callback: get session", "error", err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + rawPending, _ := session.Values[connectionPendingKey].(string) + delete(session.Values, connectionPendingKey) + + pending, err := decodePending(rawPending) + if err != nil { + slog.Warn("Connection callback: missing/invalid pending state", "error", err) + s.flashAndRedirect(w, r, "Connection request expired or invalid. Try again.", "/", http.StatusSeeOther) + return + } + + if got := q.Get("state"); got == "" || got != pending.State { + slog.Warn("Connection callback: state mismatch", "user", user.Username, "provider", pending.Provider) + s.flashAndRedirect(w, r, "Connection failed: state mismatch.", "/", http.StatusSeeOther) + return + } + + if err := s.saveSession(w, r, session); err != nil { + slog.Error("Connection callback: save session", "error", err) + } + + code := q.Get("code") + if code == "" { + s.flashAndRedirect(w, r, "Connection failed: no code returned.", pending.ReturnTo, http.StatusSeeOther) + return + } + + conn, err := s.Connections.ExchangeCode( + r.Context(), + pending.Provider, + user.Username, + s.connectionCallbackURL(r), + code, + splitScopes(pending.Scopes), + ) + if err != nil { + slog.Error("Connection callback: exchange failed", "provider", pending.Provider, "user", user.Username, "error", err) + switch { + case errors.Is(err, connections.ErrProviderDisabled): + s.flashAndRedirect(w, r, "Provider not configured on the server.", pending.ReturnTo, http.StatusSeeOther) + default: + s.flashAndRedirect(w, r, "Could not finish connection.", pending.ReturnTo, http.StatusSeeOther) + } + return + } + + slog.Info("Connection established", "provider", conn.Provider, "user", user.Username, "external_id", conn.ExternalID) + http.Redirect(w, r, pending.ReturnTo, http.StatusSeeOther) +} + +// handleConnectionDisconnect deletes one of the user's connections. +// +// POST /connections/{id}/disconnect +func (s *Server) handleConnectionDisconnect(w http.ResponseWriter, r *http.Request) { + user := GetUser(r) + idStr := r.PathValue("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + http.Error(w, "Bad id", http.StatusBadRequest) + return + } + + if err := s.Connections.Disconnect(r.Context(), uint(id), user.Username); err != nil && !errors.Is(err, connections.ErrConnectionNotFound) { + slog.Error("Connection disconnect failed", "error", err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + + returnTo := r.FormValue("return_to") + if !isSafeReturnTo(returnTo) { + returnTo = "/connections" + } + http.Redirect(w, r, returnTo, http.StatusSeeOther) +} + +// --- helpers --- + +// isSafeReturnTo accepts only same-origin paths to prevent open-redirect +// abuse. Anything starting with "//" or containing a scheme is rejected. +// Backslashes are rejected outright: url.Parse treats "\" as an ordinary +// path byte, but browsers normalize "/\evil.com" to the protocol-relative +// "//evil.com", so a backslash anywhere would defeat the "//" guard. +func isSafeReturnTo(p string) bool { + if p == "" { + return false + } + if !strings.HasPrefix(p, "/") { + return false + } + if strings.HasPrefix(p, "//") { + return false + } + if strings.Contains(p, "\\") { + return false + } + if u, err := url.Parse(p); err != nil || u.Scheme != "" || u.Host != "" { + return false + } + return true +} + +func normalizeScopes(s string) string { + if s == "" { + return "" + } + parts := strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ' ' }) + return strings.Join(parts, " ") +} + +func splitScopes(s string) []string { + if s == "" { + return nil + } + return strings.Fields(s) +} + +func mustEncodePending(p connectionPending) string { + b, err := json.Marshal(p) + if err != nil { + // connectionPending is plain strings; marshal cannot fail. + panic(err) + } + return string(b) +} + +func decodePending(raw string) (connectionPending, error) { + var p connectionPending + if raw == "" { + return p, errors.New("no pending state in session") + } + if err := json.Unmarshal([]byte(raw), &p); err != nil { + return p, err + } + if p.State == "" || p.Provider == "" { + return p, errors.New("incomplete pending state") + } + return p, nil +} diff --git a/internal/server/handlers_connections_test.go b/internal/server/handlers_connections_test.go new file mode 100644 index 00000000..9a2fa991 --- /dev/null +++ b/internal/server/handlers_connections_test.go @@ -0,0 +1,243 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync/atomic" + "testing" + + "tronbyt-server/internal/connections" + "tronbyt-server/internal/data" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +// fakeProviderServer is a self-contained mock OAuth2 provider for the +// integration tests in this package. It records hits on /token so we can +// assert the handler called the expected endpoints. +type fakeProviderServer struct { + server *httptest.Server + tokenHits atomic.Int32 + authzHits atomic.Int32 + + athleteID string +} + +func newFakeProviderServer(t *testing.T, athleteID string) *fakeProviderServer { + t.Helper() + fp := &fakeProviderServer{athleteID: athleteID} + + mux := http.NewServeMux() + mux.HandleFunc("/authorize", func(w http.ResponseWriter, _ *http.Request) { + fp.authzHits.Add(1) + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + fp.tokenHits.Add(1) + _ = r.ParseForm() + body := map[string]any{ + "access_token": "fake-access-token", + "refresh_token": "fake-refresh-token", + "token_type": "Bearer", + "expires_in": 3600, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(body) + }) + fp.server = httptest.NewServer(mux) + t.Cleanup(fp.server.Close) + return fp +} + +func (fp *fakeProviderServer) provider() *connections.Provider { + return &connections.Provider{ + Name: "fake", + DisplayName: "Fake", + AuthorizeURL: fp.server.URL + "/authorize", + TokenURL: fp.server.URL + "/token", + DefaultScopes: []string{"read"}, + // Identify returns canned values without an HTTP call — production + // providers like Strava do their own GET. + Identify: func(_ context.Context, _ string) (string, string, error) { + return fp.athleteID, "Test Athlete", nil + }, + } +} + +// makeServerWithFakeProvider builds a Server pre-loaded with a fake +// provider, a real DB user, and an active session cookie. +func makeServerWithFakeProvider(t *testing.T) (*Server, *fakeProviderServer, string /* sessionCookie */) { + t.Helper() + s := newTestServer(t) + fp := newFakeProviderServer(t, "12345") + + registry := connections.NewRegistry(fp.provider()) + s.ConnectionsRegistry = registry + s.Connections = &connections.Service{ + DB: s.DB, + Registry: registry, + Secret: "testsecret", + GetCreds: func(provider string) (string, string) { + if provider == "fake" { + return "test-client-id", "test-client-secret" + } + return "", "" + }, + } + + require.NoError(t, s.DB.Create(&data.User{Username: "alice", APIKey: "k"}).Error) + + // Forge a session cookie tied to the test secret. + rec := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + session, err := s.Store.Get(r, "session-name") + require.NoError(t, err) + session.Values["username"] = "alice" + require.NoError(t, session.Save(r, rec)) + + cookie := firstCookie(t, rec.Header().Values("Set-Cookie")) + return s, fp, cookie +} + +// firstCookie pulls just the "name=value" segment from the first Set-Cookie +// header so it can be sent back as a Cookie request header. +func firstCookie(t *testing.T, headers []string) string { + t.Helper() + require.NotEmpty(t, headers, "expected at least one Set-Cookie") + c := headers[0] + if i := strings.Index(c, ";"); i > 0 { + c = c[:i] + } + return c +} + +func TestConnectionStartRedirectsToProvider(t *testing.T) { + s, fp, cookie := makeServerWithFakeProvider(t) + + req := httptest.NewRequest(http.MethodGet, "/connections/start/fake?return_to=%2Fdevices%2Fabc%2F123%2Fconfig", nil) + req.Header.Set("Cookie", cookie) + rr := httptest.NewRecorder() + s.ServeHTTP(rr, req) + + require.Equal(t, http.StatusSeeOther, rr.Code) + loc := rr.Header().Get("Location") + require.NotEmpty(t, loc) + + u, err := url.Parse(loc) + require.NoError(t, err) + assert.Equal(t, fp.server.URL+"/authorize", strings.Split(loc, "?")[0]) + assert.NotEmpty(t, u.Query().Get("state")) + assert.Equal(t, "test-client-id", u.Query().Get("client_id")) + assert.Contains(t, u.Query().Get("redirect_uri"), "/oauth-callback") +} + +func TestConnectionStartUnknownProvider(t *testing.T) { + s, _, cookie := makeServerWithFakeProvider(t) + + req := httptest.NewRequest(http.MethodGet, "/connections/start/nope", nil) + req.Header.Set("Cookie", cookie) + rr := httptest.NewRecorder() + s.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusNotFound, rr.Code) +} + +func TestConnectionStartUnconfiguredProvider(t *testing.T) { + s, _, cookie := makeServerWithFakeProvider(t) + s.Connections.GetCreds = func(string) (string, string) { return "", "" } + + req := httptest.NewRequest(http.MethodGet, "/connections/start/fake", nil) + req.Header.Set("Cookie", cookie) + rr := httptest.NewRecorder() + s.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusServiceUnavailable, rr.Code) +} + +func TestConnectionCallbackHappyPath(t *testing.T) { + s, fp, cookie := makeServerWithFakeProvider(t) + + // Step 1: kick off the flow so the session captures pending state. + startReq := httptest.NewRequest(http.MethodGet, "/connections/start/fake?return_to=%2Fdevices%2Fabc%2F123%2Fconfig&scopes=read,activity:read", nil) + startReq.Header.Set("Cookie", cookie) + startRec := httptest.NewRecorder() + s.ServeHTTP(startRec, startReq) + require.Equal(t, http.StatusSeeOther, startRec.Code) + + cookie2 := firstCookie(t, startRec.Header().Values("Set-Cookie")) + + loc, err := url.Parse(startRec.Header().Get("Location")) + require.NoError(t, err) + state := loc.Query().Get("state") + require.NotEmpty(t, state) + + // Step 2: simulate the provider redirecting back with code+state. + cbReq := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/oauth-callback?code=auth-code-xyz&state=%s", url.QueryEscape(state)), nil) + cbReq.Header.Set("Cookie", cookie2) + cbRec := httptest.NewRecorder() + s.ServeHTTP(cbRec, cbReq) + + require.Equal(t, http.StatusSeeOther, cbRec.Code) + assert.Equal(t, "/devices/abc/123/config", cbRec.Header().Get("Location")) + assert.GreaterOrEqual(t, fp.tokenHits.Load(), int32(1)) + + // Step 3: a Connection row was created for alice. + var conn data.Connection + require.NoError(t, s.DB.Where("user_id = ? AND provider = ?", "alice", "fake").First(&conn).Error) + assert.Equal(t, "12345", conn.ExternalID) + assert.NotEmpty(t, conn.AccessToken) + assert.NotEmpty(t, conn.RefreshToken) + // Tokens are encrypted: ciphertext must not contain the plaintext. + assert.NotContains(t, string(conn.AccessToken), "fake-access-token") +} + +func TestConnectionCallbackStateMismatch(t *testing.T) { + s, _, cookie := makeServerWithFakeProvider(t) + + // Start a flow first so the session has a pending state. + startReq := httptest.NewRequest(http.MethodGet, "/connections/start/fake", nil) + startReq.Header.Set("Cookie", cookie) + startRec := httptest.NewRecorder() + s.ServeHTTP(startRec, startReq) + cookie2 := firstCookie(t, startRec.Header().Values("Set-Cookie")) + + cbReq := httptest.NewRequest(http.MethodGet, "/oauth-callback?code=x&state=tampered", nil) + cbReq.Header.Set("Cookie", cookie2) + cbRec := httptest.NewRecorder() + s.ServeHTTP(cbRec, cbReq) + + // flashAndRedirect issues a 303 to / — we just want to confirm we did + // NOT create a Connection row. + assert.Equal(t, http.StatusSeeOther, cbRec.Code) + var count int64 + require.NoError(t, s.DB.Model(&data.Connection{}).Count(&count).Error) + assert.Equal(t, int64(0), count) +} + +func TestDisconnectRemovesConnection(t *testing.T) { + s, _, cookie := makeServerWithFakeProvider(t) + + conn := data.Connection{UserID: "alice", Provider: "fake"} + require.NoError(t, s.DB.Create(&conn).Error) + + form := url.Values{} + form.Set("return_to", "/") + req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/connections/%d/disconnect", conn.ID), strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Cookie", cookie) + rr := httptest.NewRecorder() + s.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusSeeOther, rr.Code) + + err := s.DB.Where("id = ?", conn.ID).First(&data.Connection{}).Error + assert.True(t, errors.Is(err, gorm.ErrRecordNotFound)) +} diff --git a/internal/server/handlers_device_flow.go b/internal/server/handlers_device_flow.go new file mode 100644 index 00000000..94308dc4 --- /dev/null +++ b/internal/server/handlers_device_flow.go @@ -0,0 +1,264 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "net/http" + "strings" + "sync" + "time" + + "tronbyt-server/internal/connections" + "tronbyt-server/internal/data" + + "golang.org/x/oauth2" + "gorm.io/gorm" +) + +// Device authorization grant (RFC 8628). The user clicks Connect, the +// server asks the provider for a short code, and that code is shown both +// in the browser and *on the matrix itself*. The user approves on their +// phone; a background goroutine polls the token endpoint and stores the +// connection when it lands. No redirect URI, no callback host — which is +// what makes this work on a device sitting on a LAN with no public name. + +// deviceFlowStatus is the state a pending flow reports to the page. +type deviceFlowStatus string + +const ( + deviceFlowPending deviceFlowStatus = "pending" + deviceFlowConnected deviceFlowStatus = "connected" + deviceFlowDenied deviceFlowStatus = "denied" + deviceFlowExpired deviceFlowStatus = "expired" + deviceFlowFailed deviceFlowStatus = "failed" +) + +// deviceFlow is one in-flight device authorization. These live in memory +// only: a flow is worth less than the code's lifetime, and a server +// restart mid-flow just means clicking Connect again. +type deviceFlow struct { + ID string + Provider string + Username string + + UserCode string + VerificationURI string + VerificationURIComplete string + ExpiresAt time.Time + + mu sync.Mutex + status deviceFlowStatus + label string // "Connected as " once done + errMsg string +} + +func (f *deviceFlow) snapshot() (deviceFlowStatus, string, string) { + f.mu.Lock() + defer f.mu.Unlock() + return f.status, f.label, f.errMsg +} + +func (f *deviceFlow) set(status deviceFlowStatus, label, errMsg string) { + f.mu.Lock() + defer f.mu.Unlock() + f.status, f.label, f.errMsg = status, label, errMsg +} + +// deviceFlowTTL bounds how long a finished flow stays readable by the +// status endpoint before it's swept. +const deviceFlowTTL = 20 * time.Minute + +// putDeviceFlow stores a flow and opportunistically sweeps stale ones. +func (s *Server) putDeviceFlow(f *deviceFlow) { + s.deviceFlowsMu.Lock() + defer s.deviceFlowsMu.Unlock() + if s.deviceFlows == nil { + s.deviceFlows = make(map[string]*deviceFlow) + } + cutoff := time.Now().Add(-deviceFlowTTL) + for id, existing := range s.deviceFlows { + if existing.ExpiresAt.Before(cutoff) { + delete(s.deviceFlows, id) + } + } + s.deviceFlows[f.ID] = f +} + +func (s *Server) getDeviceFlow(id string) (*deviceFlow, bool) { + s.deviceFlowsMu.RLock() + defer s.deviceFlowsMu.RUnlock() + f, ok := s.deviceFlows[id] + return f, ok +} + +// handleDeviceFlowStart kicks off a device authorization and renders the +// waiting page. +// +// GET /connections/device/{provider}?return_to=... +func (s *Server) handleDeviceFlowStart(w http.ResponseWriter, r *http.Request) { + user := GetUser(r) + providerName := strings.ToLower(r.PathValue("provider")) + + provider, ok := s.ConnectionsRegistry.Get(providerName) + if !ok { + http.Error(w, "Unknown provider", http.StatusNotFound) + return + } + if !s.Connections.DeviceFlowAvailable(provider) { + http.Error(w, "This provider is not configured for device login on this server. Ask your admin to set "+ + strings.ToUpper(provider.Name)+"_CLIENT_ID.", http.StatusServiceUnavailable) + return + } + + _, da, err := s.Connections.StartDeviceAuth(r.Context(), provider.Name, nil) + if err != nil { + slog.Error("Device flow: start failed", "provider", provider.Name, "user", user.Username, "error", err) + s.flashAndRedirect(w, r, "Could not start device login: "+err.Error(), "/connections", http.StatusSeeOther) + return + } + + id, err := generateSecureTokenEncoded(16) + if err != nil { + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + + flow := &deviceFlow{ + ID: id, + Provider: provider.Name, + Username: user.Username, + UserCode: da.UserCode, + VerificationURI: da.VerificationURI, + VerificationURIComplete: da.VerificationURIComplete, + ExpiresAt: da.Expiry, + status: deviceFlowPending, + } + s.putDeviceFlow(flow) + + // Show the code on the user's displays, and start polling. Both + // outlive this request: the user may close the tab and still approve. + s.pushDeviceCodeToDisplays(r.Context(), user, provider.DisplayName, da.UserCode, da.VerificationURI) + go s.pollDeviceFlow(flow, da) + + returnTo := r.URL.Query().Get("return_to") + if !isSafeReturnTo(returnTo) { + returnTo = "/connections" + } + + s.renderTemplate(w, r, "deviceconnect", TemplateData{ + DeviceFlow: &DeviceFlowView{ + ID: flow.ID, + ProviderName: provider.Name, + ProviderDisplayName: provider.DisplayName, + UserCode: flow.UserCode, + VerificationURI: flow.VerificationURI, + VerificationURIComplete: flow.VerificationURIComplete, + ExpiresInSeconds: int(time.Until(flow.ExpiresAt).Seconds()), + ShownOnDisplays: len(user.Devices), + ReturnTo: returnTo, + }, + }) +} + +// pollDeviceFlow blocks on the provider's token endpoint until the user +// approves, denies, or the code expires, then records the outcome and +// clears the code from the displays. +func (s *Server) pollDeviceFlow(flow *deviceFlow, da *oauth2.DeviceAuthResponse) { + // Bound the poll by the code's own lifetime; DeviceAccessToken honors + // the deadline, and this guarantees the goroutine always exits. + deadline := flow.ExpiresAt + if deadline.IsZero() { + deadline = time.Now().Add(15 * time.Minute) + } + ctx, cancel := context.WithDeadline(context.Background(), deadline.Add(5*time.Second)) + defer cancel() + + conn, err := s.Connections.CompleteDeviceAuth(ctx, flow.Provider, flow.Username, da, nil) + + // Whatever happened, the code on the display is now stale. + defer func() { + user, uerr := s.loadUserWithDevices(context.Background(), flow.Username) + if uerr == nil { + s.clearDeviceCodeFromDisplays(user) + } + }() + + if err != nil { + status, msg := classifyDeviceFlowError(err) + slog.Warn("Device flow: not completed", "provider", flow.Provider, "user", flow.Username, "status", status, "error", err) + flow.set(status, "", msg) + return + } + + label := conn.DisplayName + if label == "" { + label = conn.ExternalID + } + slog.Info("Device flow: connection established", "provider", conn.Provider, "user", flow.Username, "external_id", conn.ExternalID) + flow.set(deviceFlowConnected, label, "") +} + +// classifyDeviceFlowError turns a polling failure into a status the page +// can show. RFC 8628 defines the terminal error codes; providers that +// return non-standard shapes fall through to a generic failure. +func classifyDeviceFlowError(err error) (deviceFlowStatus, string) { + var retrieve *oauth2.RetrieveError + if errors.As(err, &retrieve) { + switch retrieve.ErrorCode { + // "authorization_declined" is Microsoft's spelling of access_denied. + case "access_denied", "authorization_declined": + return deviceFlowDenied, "You declined the request." + case "expired_token", "bad_verification_code": + return deviceFlowExpired, "The code expired. Start again to get a new one." + case "device_flow_disabled": + return deviceFlowFailed, "Device login is not enabled on the server's OAuth app for this provider." + } + } + if errors.Is(err, context.DeadlineExceeded) { + return deviceFlowExpired, "The code expired. Start again to get a new one." + } + if errors.Is(err, connections.ErrProviderDisabled) { + return deviceFlowFailed, "This provider is not configured on the server." + } + return deviceFlowFailed, "Could not complete the connection." +} + +// handleDeviceFlowStatus is polled by the waiting page. +// +// GET /connections/device/status/{id} +func (s *Server) handleDeviceFlowStatus(w http.ResponseWriter, r *http.Request) { + user := GetUser(r) + + flow, ok := s.getDeviceFlow(r.PathValue("id")) + // A flow belongs to the user who started it; anyone else gets the same + // answer as for an unknown id. + if !ok || flow.Username != user.Username { + http.Error(w, "Unknown device login", http.StatusNotFound) + return + } + + status, label, errMsg := flow.snapshot() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(map[string]any{ + "status": string(status), + "label": label, + "error": errMsg, + }); err != nil { + slog.Error("Device flow: failed to write status", "error", err) + } +} + +// loadUserWithDevices re-reads a user (with devices) outside a request, +// for the background poller. +func (s *Server) loadUserWithDevices(ctx context.Context, username string) (*data.User, error) { + user, err := gorm.G[data.User](s.DB). + Preload("Devices", nil). + Where("username = ?", username). + First(ctx) + if err != nil { + return nil, err + } + return &user, nil +} diff --git a/internal/server/handlers_device_flow_test.go b/internal/server/handlers_device_flow_test.go new file mode 100644 index 00000000..9613e171 --- /dev/null +++ b/internal/server/handlers_device_flow_test.go @@ -0,0 +1,372 @@ +package server + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "tronbyt-server/internal/connections" + "tronbyt-server/internal/data" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" +) + +// fakeDeviceProvider implements the RFC 8628 endpoints: it hands out a +// user code, reports authorization_pending for a configurable number of +// polls, then issues a token — the same shape GitHub presents. +type fakeDeviceProvider struct { + server *httptest.Server + + pendingPolls int32 // polls answered with authorization_pending + polls atomic.Int32 + deviceHits atomic.Int32 + + // sentSecret records whether a client_secret reached the token + // endpoint, so tests can assert public-client behavior. + sentSecret atomic.Bool + // terminalError, when set, is returned instead of a token. + terminalError string +} + +func newFakeDeviceProvider(t *testing.T, pendingPolls int32) *fakeDeviceProvider { + t.Helper() + fp := &fakeDeviceProvider{pendingPolls: pendingPolls} + + mux := http.NewServeMux() + mux.HandleFunc("/device/code", func(w http.ResponseWriter, r *http.Request) { + fp.deviceHits.Add(1) + _ = r.ParseForm() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "device_code": "device-code-xyz", + "user_code": "WDJB-MJHT", + "verification_uri": "https://example.test/login/device", + "expires_in": 900, + "interval": 1, + }) + }) + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + n := fp.polls.Add(1) + _ = r.ParseForm() + if r.PostForm.Get("client_secret") != "" { + fp.sentSecret.Store(true) + } + w.Header().Set("Content-Type", "application/json") + + if fp.terminalError != "" { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]any{"error": fp.terminalError}) + return + } + if n <= fp.pendingPolls { + // GitHub answers 200 with an RFC error body while pending. + _ = json.NewEncoder(w).Encode(map[string]any{"error": "authorization_pending"}) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": "device-access-token", + "token_type": "Bearer", + "expires_in": 3600, + }) + }) + + fp.server = httptest.NewServer(mux) + t.Cleanup(fp.server.Close) + return fp +} + +func (fp *fakeDeviceProvider) provider() *connections.Provider { + return &connections.Provider{ + Name: "fakedevice", + DisplayName: "Fake Device", + TokenURL: fp.server.URL + "/token", + DeviceAuthURL: fp.server.URL + "/device/code", + DeviceAuthNeedsSecret: false, + AuthStyle: oauth2.AuthStyleInParams, + DefaultScopes: []string{"read"}, + Identify: func(context.Context, string) (string, string, error) { + return "99", "Octo Cat", nil + }, + } +} + +// makeServerWithDeviceProvider wires a server whose only provider is a +// device-flow one configured with a client id and NO secret. +func makeServerWithDeviceProvider(t *testing.T, fp *fakeDeviceProvider) (*Server, string) { + t.Helper() + s := newTestServer(t) + + registry := connections.NewRegistry(fp.provider()) + s.ConnectionsRegistry = registry + s.Connections = &connections.Service{ + DB: s.DB, + Registry: registry, + Secret: "testsecret", + GetCreds: func(provider string) (string, string) { + if provider == "fakedevice" { + return "public-client-id", "" // no secret: public client + } + return "", "" + }, + } + + require.NoError(t, s.DB.Create(&data.User{Username: "alice", APIKey: "k"}).Error) + + rec := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + session, err := s.Store.Get(r, "session-name") + require.NoError(t, err) + session.Values["username"] = "alice" + require.NoError(t, session.Save(r, rec)) + + return s, firstCookie(t, rec.Header().Values("Set-Cookie")) +} + +// TestDeviceFlowEndToEnd walks the whole grant: start the flow, see the +// code on the page, let the user "approve", and confirm the connection +// is persisted and reported by the status endpoint. +func TestDeviceFlowEndToEnd(t *testing.T) { + fp := newFakeDeviceProvider(t, 1) // pending once, then approved + s, cookie := makeServerWithDeviceProvider(t, fp) + + req := httptest.NewRequest(http.MethodGet, "/connections/device/fakedevice?return_to=%2Fconnections", nil) + req.Header.Set("Cookie", cookie) + rr := httptest.NewRecorder() + s.ServeHTTP(rr, req) + + require.Equal(t, http.StatusOK, rr.Code) + body := rr.Body.String() + assert.Contains(t, body, "WDJB-MJHT", "the page must show the user code") + assert.Contains(t, body, "example.test/login/device", "and where to enter it") + assert.Equal(t, int32(1), fp.deviceHits.Load()) + + flowID := onlyDeviceFlowID(t, s) + + // The poller runs in the background; wait for it to land. + status := waitForDeviceFlow(t, s, flowID, deviceFlowConnected) + assert.Equal(t, deviceFlowConnected, status) + assert.False(t, fp.sentSecret.Load(), "a public client must not send a client_secret") + + var conn data.Connection + require.NoError(t, s.DB.Where("user_id = ? AND provider = ?", "alice", "fakedevice").First(&conn).Error) + assert.Equal(t, "99", conn.ExternalID) + assert.Equal(t, "Octo Cat", conn.DisplayName) + assert.NotContains(t, string(conn.AccessToken), "device-access-token", "token must be encrypted at rest") + + // And the status endpoint reports it to the waiting page. + statusReq := httptest.NewRequest(http.MethodGet, "/connections/device/status/"+flowID, nil) + statusReq.Header.Set("Cookie", cookie) + statusRec := httptest.NewRecorder() + s.ServeHTTP(statusRec, statusReq) + + require.Equal(t, http.StatusOK, statusRec.Code) + var payload struct { + Status string `json:"status"` + Label string `json:"label"` + } + require.NoError(t, json.NewDecoder(statusRec.Body).Decode(&payload)) + assert.Equal(t, "connected", payload.Status) + assert.Equal(t, "Octo Cat", payload.Label) +} + +func TestDeviceFlowDenied(t *testing.T) { + fp := newFakeDeviceProvider(t, 0) + fp.terminalError = "access_denied" + s, cookie := makeServerWithDeviceProvider(t, fp) + + req := httptest.NewRequest(http.MethodGet, "/connections/device/fakedevice", nil) + req.Header.Set("Cookie", cookie) + rr := httptest.NewRecorder() + s.ServeHTTP(rr, req) + require.Equal(t, http.StatusOK, rr.Code) + + flowID := onlyDeviceFlowID(t, s) + assert.Equal(t, deviceFlowDenied, waitForDeviceFlow(t, s, flowID, deviceFlowDenied)) + + var count int64 + require.NoError(t, s.DB.Model(&data.Connection{}).Count(&count).Error) + assert.Equal(t, int64(0), count, "a denied flow must not create a connection") +} + +// TestDeviceFlowStatusIsOwnerOnly stops one user reading another's flow. +func TestDeviceFlowStatusIsOwnerOnly(t *testing.T) { + fp := newFakeDeviceProvider(t, 100) // stays pending + s, cookie := makeServerWithDeviceProvider(t, fp) + + req := httptest.NewRequest(http.MethodGet, "/connections/device/fakedevice", nil) + req.Header.Set("Cookie", cookie) + s.ServeHTTP(httptest.NewRecorder(), req) + flowID := onlyDeviceFlowID(t, s) + + // A second user with a valid session must not see it. + require.NoError(t, s.DB.Create(&data.User{Username: "mallory", APIKey: "k2"}).Error) + rec := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + session, err := s.Store.Get(r, "session-name") + require.NoError(t, err) + session.Values["username"] = "mallory" + require.NoError(t, session.Save(r, rec)) + malloryCookie := firstCookie(t, rec.Header().Values("Set-Cookie")) + + statusReq := httptest.NewRequest(http.MethodGet, "/connections/device/status/"+flowID, nil) + statusReq.Header.Set("Cookie", malloryCookie) + statusRec := httptest.NewRecorder() + s.ServeHTTP(statusRec, statusReq) + + assert.Equal(t, http.StatusNotFound, statusRec.Code) +} + +func TestDeviceFlowUnconfiguredProvider(t *testing.T) { + fp := newFakeDeviceProvider(t, 0) + s, cookie := makeServerWithDeviceProvider(t, fp) + s.Connections.GetCreds = func(string) (string, string) { return "", "" } + + req := httptest.NewRequest(http.MethodGet, "/connections/device/fakedevice", nil) + req.Header.Set("Cookie", cookie) + rr := httptest.NewRecorder() + s.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusServiceUnavailable, rr.Code) +} + +// TestDeviceFlowAvailableWithoutSecret is the point of the public-client +// path: an admin enables GitHub with a client id alone. +func TestDeviceFlowAvailableWithoutSecret(t *testing.T) { + fp := newFakeDeviceProvider(t, 0) + s, _ := makeServerWithDeviceProvider(t, fp) + + provider, ok := s.ConnectionsRegistry.Get("fakedevice") + require.True(t, ok) + + assert.True(t, s.Connections.DeviceFlowAvailable(provider)) + assert.False(t, s.Connections.CodeFlowAvailable(provider), + "no secret and no authorize URL means no redirect flow") + assert.True(t, s.anyConnectionProviderConfigured()) +} + +// TestAnnotateSchemaMarksDeviceFlowProvider covers an app config page +// whose schema declares a device-flow provider: it must read as +// configured on a server that set only a client id, and the UI must be +// told to offer the code flow rather than a redirect. +func TestAnnotateSchemaMarksDeviceFlowProvider(t *testing.T) { + s := newTestServer(t) + s.ConnectionsRegistry = connections.NewRegistry(connections.GitHub()) + s.Connections = &connections.Service{ + DB: s.DB, + Registry: s.ConnectionsRegistry, + Secret: "testsecret", + GetCreds: func(provider string) (string, string) { + if provider == "github" { + return "public-client-id", "" // client id only, no secret + } + return "", "" + }, + } + require.NoError(t, s.DB.Create(&data.User{Username: "alice", APIKey: "k"}).Error) + + in := []byte(`{"schema":[ + {"type":"oauth2","id":"gh","authorization_endpoint":"https://github.com/login/oauth/authorize"} + ]}`) + out := s.annotateSchemaForUI(context.Background(), in, "alice") + + var doc struct { + Schema []map[string]any `json:"schema"` + } + require.NoError(t, json.Unmarshal(out, &doc)) + require.Len(t, doc.Schema, 1) + + assert.Equal(t, true, doc.Schema[0]["tronbyt_configured"], + "a device-flow provider needs no secret to be usable") + assert.Equal(t, true, doc.Schema[0]["tronbyt_device_flow"]) + assert.Equal(t, "github", doc.Schema[0]["tronbyt_provider"]) +} + +func TestClassifyDeviceFlowError(t *testing.T) { + cases := []struct { + name string + err error + want deviceFlowStatus + }{ + {"denied", &oauth2.RetrieveError{ErrorCode: "access_denied"}, deviceFlowDenied}, + {"microsoft denied", &oauth2.RetrieveError{ErrorCode: "authorization_declined"}, deviceFlowDenied}, + {"expired", &oauth2.RetrieveError{ErrorCode: "expired_token"}, deviceFlowExpired}, + {"deadline", fmt.Errorf("polling: %w", context.DeadlineExceeded), deviceFlowExpired}, + {"device flow off", &oauth2.RetrieveError{ErrorCode: "device_flow_disabled"}, deviceFlowFailed}, + {"unknown", fmt.Errorf("boom"), deviceFlowFailed}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, msg := classifyDeviceFlowError(tc.err) + assert.Equal(t, tc.want, got) + assert.NotEmpty(t, msg) + }) + } +} + +// --- helpers --- + +func onlyDeviceFlowID(t *testing.T, s *Server) string { + t.Helper() + s.deviceFlowsMu.RLock() + defer s.deviceFlowsMu.RUnlock() + require.Len(t, s.deviceFlows, 1) + for id := range s.deviceFlows { + return id + } + return "" +} + +// waitForDeviceFlow polls the in-memory flow until it leaves the pending +// state or the test gives up. +func waitForDeviceFlow(t *testing.T, s *Server, id string, want deviceFlowStatus) deviceFlowStatus { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + flow, ok := s.getDeviceFlow(id) + require.True(t, ok) + if status, _, _ := flow.snapshot(); status != deviceFlowPending { + return status + } + time.Sleep(50 * time.Millisecond) + } + t.Fatalf("device flow %s never reached %s", id, want) + return "" +} + +// TestDeviceCodePushedToDisplay asserts the code actually reaches the +// device as an ephemeral frame — the part that makes this feel magic. +func TestDeviceCodePushedToDisplay(t *testing.T) { + fp := newFakeDeviceProvider(t, 100) // stays pending so the frame persists + s, cookie := makeServerWithDeviceProvider(t, fp) + + device := data.Device{ID: "dev1", Username: "alice", Name: "Shelf"} + require.NoError(t, s.DB.Create(&device).Error) + + req := httptest.NewRequest(http.MethodGet, "/connections/device/fakedevice", nil) + req.Header.Set("Cookie", cookie) + rr := httptest.NewRecorder() + s.ServeHTTP(rr, req) + require.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), "also showing on your display") + + path, err := s.deviceCodeImagePath("dev1") + require.NoError(t, err) + assert.FileExists(t, path, "the user code should be queued as an ephemeral frame") + assert.True(t, strings.HasPrefix(filepathBase(path), "__"), + "ephemeral frames are consumed once and deleted by the rotation") +} + +func filepathBase(p string) string { + if i := strings.LastIndex(p, "/"); i >= 0 { + return p[i+1:] + } + return p +} diff --git a/internal/server/handlers_user_import_test.go b/internal/server/handlers_user_import_test.go index 54c188f7..9af9ab23 100644 --- a/internal/server/handlers_user_import_test.go +++ b/internal/server/handlers_user_import_test.go @@ -24,7 +24,7 @@ func TestHandleImportUserConfig_Legacy(t *testing.T) { // Setup DB db, err := gorm.Open(sqlite.Open("file::memory:?cache=private"), &gorm.Config{}) assert.NoError(t, err) - err = db.AutoMigrate(&data.User{}, &data.Device{}, &data.App{}, &data.WebAuthnCredential{}) + err = db.AutoMigrate(&data.User{}, &data.Device{}, &data.App{}, &data.WebAuthnCredential{}, &data.Connection{}) assert.NoError(t, err) // Create existing user @@ -102,7 +102,7 @@ func TestHandleImportUserConfig_AppIDReset(t *testing.T) { // Setup DB db, err := gorm.Open(sqlite.Open("file::memory:?cache=private"), &gorm.Config{}) assert.NoError(t, err) - err = db.AutoMigrate(&data.User{}, &data.Device{}, &data.App{}, &data.WebAuthnCredential{}) + err = db.AutoMigrate(&data.User{}, &data.Device{}, &data.App{}, &data.WebAuthnCredential{}, &data.Connection{}) assert.NoError(t, err) // Create existing user diff --git a/internal/server/helpers.go b/internal/server/helpers.go index afd840af..4413e6bf 100644 --- a/internal/server/helpers.go +++ b/internal/server/helpers.go @@ -124,6 +124,25 @@ type TemplateData struct { OIDCUsernameClaim string OIDCAdminGroupClaim string OIDCAdminGroupValue string + + // Third-party connections (/connections page and nav link) + ConnectionProviders []ConnectionProviderView + HasConnections bool + DeviceFlow *DeviceFlowView +} + +// DeviceFlowView is the waiting page's model for one in-flight device +// authorization. +type DeviceFlowView struct { + ID string + ProviderName string + ProviderDisplayName string + UserCode string + VerificationURI string + VerificationURIComplete string + ExpiresInSeconds int + ShownOnDisplays int + ReturnTo string } // CreateDeviceFormData represents the form data for creating a device. @@ -170,6 +189,10 @@ func (s *Server) renderTemplate(w http.ResponseWriter, r *http.Request, name str tmplData.UpdateAvailable = s.UpdateAvailable tmplData.LatestReleaseURL = s.LatestReleaseURL + // Show the Connections nav link only when the admin has configured at + // least one provider's client credentials. + tmplData.HasConnections = s.anyConnectionProviderConfigured() + // Get User from session if not provided in tmplData session, _ := s.Store.Get(r, "session-name") if tmplData.User == nil { diff --git a/internal/server/metrics_test.go b/internal/server/metrics_test.go index 61d8d317..b9b43e59 100644 --- a/internal/server/metrics_test.go +++ b/internal/server/metrics_test.go @@ -19,7 +19,7 @@ func TestMetricsEndpoint(t *testing.T) { if err != nil { t.Fatalf("Failed to open DB: %v", err) } - if err := db.AutoMigrate(&data.User{}, &data.Device{}, &data.App{}, &data.Setting{}); err != nil { + if err := db.AutoMigrate(&data.User{}, &data.Device{}, &data.App{}, &data.Setting{}, &data.Connection{}); err != nil { t.Fatalf("Failed to migrate DB: %v", err) } diff --git a/internal/server/render_utils.go b/internal/server/render_utils.go index c3134cf4..25868530 100644 --- a/internal/server/render_utils.go +++ b/internal/server/render_utils.go @@ -77,6 +77,13 @@ func (s *Server) RenderApp(ctx context.Context, device *data.Device, app *data.A return content, nil, nil } + // 3. Inject fresh access tokens for any third-party connections this + // app's schema declares (Strava etc.). No-op if the device has no + // owner, the app has no OAuth2 fields, or the user hasn't connected. + if device != nil && device.Username != "" { + s.injectConnectionTokens(ctx, config, appPath, device.Username) + } + return renderer.Render( ctx, appPath, diff --git a/internal/server/server.go b/internal/server/server.go index 79cce939..aa49a4bd 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -21,6 +21,7 @@ import ( "tronbyt-server/internal/apps" "tronbyt-server/internal/config" + "tronbyt-server/internal/connections" syncer "tronbyt-server/internal/sync" "tronbyt-server/web" @@ -50,6 +51,22 @@ type Server struct { metrics *appMetrics OIDCProvider *OIDCProvider + // Connections is the third-party OAuth2 connection store (Strava etc). + // Always non-nil; provider availability is gated per-call by config. + Connections *connections.Service + ConnectionsRegistry *connections.Registry + + // oauth2FieldsCache memoizes per-app oauth2 schema fields so token + // injection doesn't re-evaluate starlark on every render. See + // oauth2FieldsForApp. + oauth2FieldsCache map[string]oauth2FieldsEntry + oauth2FieldsMu sync.RWMutex + + // deviceFlows tracks in-flight device authorization grants. In memory + // only — a flow is shorter-lived than the user code itself. + deviceFlows map[string]*deviceFlow + deviceFlowsMu sync.RWMutex + systemAppsCache []apps.AppMetadata systemAppsCacheMutex sync.RWMutex @@ -89,6 +106,8 @@ var templateFiles = map[string]string{ "update": "manager/update.html", "device_tv": "manager/device_tv.html", "settings": "admin/settings.html", + "connections": "manager/connections.html", + "deviceconnect": "manager/deviceconnect.html", } func NewServer(db *gorm.DB, cfg *config.Settings) *Server { @@ -174,6 +193,21 @@ func NewServer(db *gorm.DB, cfg *config.Settings) *Server { } } + // Third-party OAuth2 connections (Strava etc). Token encryption is keyed + // off the same secret_key used for sessions. + s.ConnectionsRegistry = connections.NewRegistry( + connections.Strava(), + connections.Spotify(), + connections.GitHub(), + connections.Google(), + ) + s.Connections = &connections.Service{ + DB: s.DB, + Registry: s.ConnectionsRegistry, + Secret: secretKey, + GetCreds: cfg.ConnectionClientCreds, + } + s.Store = sessions.NewCookieStore([]byte(secretKey)) // Configure Session Store diff --git a/internal/server/server_test.go b/internal/server/server_test.go index d5adb5d5..a35b8321 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -31,7 +31,7 @@ func newTestServer(t *testing.T, opts ...option) *Server { t.Fatalf("Failed to open DB: %v", err) } - if err := db.AutoMigrate(&data.User{}, &data.Device{}, &data.App{}, &data.WebAuthnCredential{}, &data.Setting{}); err != nil { + if err := db.AutoMigrate(&data.User{}, &data.Device{}, &data.App{}, &data.WebAuthnCredential{}, &data.Setting{}, &data.Connection{}); err != nil { t.Fatalf("Failed to migrate DB: %v", err) } diff --git a/web/i18n/de.json b/web/i18n/de.json index 79802425..7e991d86 100644 --- a/web/i18n/de.json +++ b/web/i18n/de.json @@ -2143,5 +2143,53 @@ }, "You are accessing this server via localhost. For proper firmware generation you should use:": { "other": "Sie greifen über localhost auf diesen Server zu. Für eine korrekte Firmware-Generierung sollten Sie Folgendes verwenden:" + }, + "Connections": { + "other": "Verbindungen" + }, + "Connected Accounts": { + "other": "Verbundene Konten" + }, + "Connect an account once, and any app that uses it renders with a fresh token automatically — no API keys to paste.": { + "other": "Verbinden Sie ein Konto einmal, und jede App, die es nutzt, wird automatisch mit einem frischen Token gerendert — keine API-Schlüssel zum Einfügen." + }, + "No connection providers are configured on this server. To enable one, register an OAuth application with the provider and set its client ID and secret in the server environment (see .env.example).": { + "other": "Auf diesem Server sind keine Verbindungsanbieter konfiguriert. Registrieren Sie zum Aktivieren eine OAuth-Anwendung beim Anbieter und hinterlegen Sie deren Client-ID und Secret in der Server-Umgebung (siehe .env.example)." + }, + "Connected as": { + "other": "Verbunden als" + }, + "Scopes:": { + "other": "Berechtigungen:" + }, + "Not connected": { + "other": "Nicht verbunden" + }, + "Connected": { + "other": "Verbunden" + }, + "Disconnect": { + "other": "Trennen" + }, + "Connect": { + "other": "Verbinden" + }, + "Connect with a code": { + "other": "Mit einem Code verbinden" + }, + "Shows a short code here and on your display": { + "other": "Zeigt einen kurzen Code hier und auf Ihrem Display an" + }, + "Go to this address on your phone or computer and enter the code:": { + "other": "Öffnen Sie diese Adresse auf Ihrem Telefon oder Computer und geben Sie den Code ein:" + }, + "This code is also showing on your display.": { + "other": "Dieser Code wird auch auf Ihrem Display angezeigt." + }, + "Waiting for you to approve…": { + "other": "Warten auf Ihre Bestätigung…" + }, + "Continue": { + "other": "Weiter" } } diff --git a/web/i18n/en.json b/web/i18n/en.json index cea78880..a5a0e1db 100644 --- a/web/i18n/en.json +++ b/web/i18n/en.json @@ -2146,5 +2146,53 @@ }, "You are accessing this server via localhost. For proper firmware generation you should use:": { "other": "You are accessing this server via localhost. For proper firmware generation you should use:" + }, + "Connections": { + "other": "Connections" + }, + "Connected Accounts": { + "other": "Connected Accounts" + }, + "Connect an account once, and any app that uses it renders with a fresh token automatically \u2014 no API keys to paste.": { + "other": "Connect an account once, and any app that uses it renders with a fresh token automatically \u2014 no API keys to paste." + }, + "No connection providers are configured on this server. To enable one, register an OAuth application with the provider and set its client ID and secret in the server environment (see .env.example).": { + "other": "No connection providers are configured on this server. To enable one, register an OAuth application with the provider and set its client ID and secret in the server environment (see .env.example)." + }, + "Connected as": { + "other": "Connected as" + }, + "Scopes:": { + "other": "Scopes:" + }, + "Not connected": { + "other": "Not connected" + }, + "Connected": { + "other": "Connected" + }, + "Disconnect": { + "other": "Disconnect" + }, + "Connect": { + "other": "Connect" + }, + "Connect with a code": { + "other": "Connect with a code" + }, + "Shows a short code here and on your display": { + "other": "Shows a short code here and on your display" + }, + "Go to this address on your phone or computer and enter the code:": { + "other": "Go to this address on your phone or computer and enter the code:" + }, + "This code is also showing on your display.": { + "other": "This code is also showing on your display." + }, + "Waiting for you to approve\u2026": { + "other": "Waiting for you to approve\u2026" + }, + "Continue": { + "other": "Continue" } } diff --git a/web/templates/base.html b/web/templates/base.html index 563f2b30..98fdc720 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -47,6 +47,11 @@

  • {{ t .Localizer "System Settings" }}
  • + {{ if .HasConnections }} +
  • + {{ t .Localizer "Connections" }} +
  • + {{ end }} {{ if not .IsAutoLoginActive }}
  • {{ t .Localizer "Log Out" }} @@ -104,6 +109,11 @@

  • {{ t .Localizer "System Settings" }}
  • + {{ if .HasConnections }} +
  • + {{ t .Localizer "Connections" }} +
  • + {{ end }} {{ if not .IsAutoLoginActive }}
  • {{ t .Localizer "Log Out" }} diff --git a/web/templates/manager/configapp.html b/web/templates/manager/configapp.html index 591f2e29..efd8f524 100644 --- a/web/templates/manager/configapp.html +++ b/web/templates/manager/configapp.html @@ -949,6 +949,9 @@

    {{ t .Localizer "Clear Schedule" }}

    case "typeahead": inputElement = createTypeaheadField(field, config); break; + case "oauth2": + inputElement = createOAuth2Field(field); + break; default: inputElement = document.createElement("input"); inputElement.value = config[field.id] || ""; @@ -1714,6 +1717,100 @@

    {{ t .Localizer "Clear Schedule" }}

    return inputElement; } + // createOAuth2Field renders a Connect/Connected button for an OAuth2 + // schema field. The actual OAuth flow runs server-side: clicking + // "Connect" navigates to /connections/start/, which redirects + // through the provider and back to /oauth-callback, then to this page. + // The annotated schema fields (tronbyt_*) tell us provider state. + function createOAuth2Field(field) { + const wrapper = document.createElement("div"); + wrapper.className = "oauth2-field"; + wrapper.dataset.configId = field.id; + wrapper.setAttribute("data-ignore-config", "true"); + + const provider = field.tronbyt_provider || ""; + const providerLabel = field.tronbyt_display_name || field.name || provider || "this provider"; + + if (!provider) { + const note = document.createElement("p"); + note.className = "flash"; + note.textContent = + "This app uses an OAuth2 provider Tronbyt does not yet recognize " + + "(authorization endpoint: " + (field.authorization_endpoint || "?") + "). " + + "Add provider support in internal/connections to enable."; + wrapper.appendChild(note); + return wrapper; + } + + if (!field.tronbyt_configured) { + const note = document.createElement("p"); + note.className = "flash"; + note.textContent = + providerLabel + " is not configured on this server. " + + "Set " + provider.toUpperCase() + "_CLIENT_ID and " + + provider.toUpperCase() + "_CLIENT_SECRET in the server env."; + wrapper.appendChild(note); + return wrapper; + } + + if (field.tronbyt_connected) { + const status = document.createElement("p"); + status.style.margin = "0 0 8px 0"; + // Providers without an Identify callback (Google) leave the label + // empty; say "Connected" rather than "Connected as Google". + status.innerHTML = ' ' + + (field.tronbyt_label ? "Connected as " + escapeHTML(field.tronbyt_label) : "Connected"); + wrapper.appendChild(status); + + const disconnect = document.createElement("form"); + disconnect.method = "POST"; + disconnect.action = "/connections/" + encodeURIComponent(field.tronbyt_connection_id) + "/disconnect"; + disconnect.style.display = "inline"; + const ret = document.createElement("input"); + ret.type = "hidden"; + ret.name = "return_to"; + ret.value = window.location.pathname + window.location.search; + disconnect.appendChild(ret); + const btn = document.createElement("button"); + btn.type = "submit"; + btn.className = "w3-button w3-round"; + btn.style.backgroundColor = "#888"; + btn.style.color = "#fff"; + btn.textContent = "Disconnect"; + disconnect.appendChild(btn); + wrapper.appendChild(disconnect); + return wrapper; + } + + const connect = document.createElement("a"); + connect.className = "w3-button w3-round"; + connect.style.backgroundColor = "var(--primary-color)"; + connect.style.color = "#fff"; + const params = new URLSearchParams(); + params.set("return_to", window.location.pathname + window.location.search); + if (field.tronbyt_scopes) { + params.set("scopes", field.tronbyt_scopes); + } + // Device-flow providers show a short code here and on the display + // instead of bouncing through a redirect URI. + if (field.tronbyt_device_flow) { + connect.textContent = "Connect " + providerLabel + " with a code"; + connect.title = "Shows a short code here and on your display"; + connect.href = "/connections/device/" + encodeURIComponent(provider) + "?" + params.toString(); + } else { + connect.textContent = "Connect " + providerLabel; + connect.href = "/connections/start/" + encodeURIComponent(provider) + "?" + params.toString(); + } + wrapper.appendChild(connect); + return wrapper; + } + + function escapeHTML(s) { + const div = document.createElement("div"); + div.textContent = String(s == null ? "" : s); + return div.innerHTML; + } + function createGeneratedField(field, config) { const sourceField = document.getElementById("schema_" + field.source); if (!sourceField) { @@ -1731,7 +1828,7 @@

    {{ t .Localizer "Clear Schedule" }}

    const response = await fetch(handlerUrl, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ id: field.id, param: sourceValue, config: config }) + body: JSON.stringify({ id: field.id, param: sourceValue, source: field.source, config: config }) }); if (!response.ok) throw new Error("Failed to fetch generated fields"); diff --git a/web/templates/manager/connections.html b/web/templates/manager/connections.html new file mode 100644 index 00000000..ccbdd1d5 --- /dev/null +++ b/web/templates/manager/connections.html @@ -0,0 +1,73 @@ +{{ define "connections" }} +{{ template "base" . }} +{{ end }} +{{ define "title" }}{{ t .Localizer "Connections" }}{{ end }} +{{ define "header" }}

    {{ t .Localizer "Connected Accounts" }}

    {{ end }} +{{ define "content" }} +
    +

    + {{ t .Localizer "Connect an account once, and any app that uses it renders with a fresh token automatically — no API keys to paste." }} +

    + {{ if not .ConnectionProviders }} +

    + {{ t .Localizer "No connection providers are configured on this server. To enable one, register an OAuth application with the provider and set its client ID and secret in the server environment (see .env.example)." }} +

    + {{ end }} + {{ range .ConnectionProviders }} +
    +
    +
    + {{ .DisplayName }} + {{ if .Connected }} +
    + + {{ if .Label }} + {{ t $.Localizer "Connected as" }} {{ .Label }} + {{ else }} + {{ t $.Localizer "Connected" }} + {{ end }} +
    + {{ if .Scopes }} +
    {{ t $.Localizer "Scopes:" }} {{ .Scopes }}
    + {{ end }} + {{ else }} +
    {{ t $.Localizer "Not connected" }}
    + {{ end }} +
    +
    + {{ if .Connected }} +
    + + +
    + {{ else if .DeviceFlow }} + {{ t $.Localizer "Connect with a code" }} + {{ else }} + {{ t $.Localizer "Connect" }} + {{ end }} +
    +
    +
    + {{ end }} +
    +{{ end }} diff --git a/web/templates/manager/deviceconnect.html b/web/templates/manager/deviceconnect.html new file mode 100644 index 00000000..c0cfaae5 --- /dev/null +++ b/web/templates/manager/deviceconnect.html @@ -0,0 +1,107 @@ +{{ define "deviceconnect" }} +{{ template "base" . }} +{{ end }} +{{ define "title" }}{{ t .Localizer "Connect" }}{{ end }} +{{ define "header" }}

    {{ t .Localizer "Connect" }} {{ .DeviceFlow.ProviderDisplayName }}

    {{ end }} +{{ define "content" }} +
    +
    +

    + {{ t .Localizer "Go to this address on your phone or computer and enter the code:" }} +

    +

    + {{ .DeviceFlow.VerificationURI }} +

    +
    {{ .DeviceFlow.UserCode }}
    + {{ if .DeviceFlow.ShownOnDisplays }} +

    + + {{ t .Localizer "This code is also showing on your display." }} +

    + {{ end }} +

    + + {{ t .Localizer "Waiting for you to approve…" }} +

    +
    + + +
    + + +{{ end }}