Überauth intervals.icu

An Ueberauth strategy for authenticating athletes with intervals.icu.

Implements the OAuth flow described in the intervals.icu OAuth support thread.

Installation

def deps do
[
{:ueberauth_intervals_icu, "~> 0.1.0"}
]
end

Registering your application

  1. Go to https://intervals.icu/settings and create an application to get a client id and client secret.
  2. Register your callback URL in full. intervals.icu does not support wildcards in redirect URIs, despite what some documentation suggests, so https://app.example.com/* will not work. Register every callback URL you use, including your development one.

Configuration

config :ueberauth, Ueberauth,
providers: [
intervals_icu: {Ueberauth.Strategy.IntervalsIcu, []}
]
config :ueberauth, Ueberauth.Strategy.IntervalsIcu.OAuth,
client_id: System.get_env("INTERVALS_ICU_CLIENT_ID"),
client_secret: System.get_env("INTERVALS_ICU_CLIENT_SECRET")

Add the routes:

scope "/auth", MyAppWeb do
pipe_through :browser
get "/:provider", AuthController, :request
get "/:provider/callback", AuthController, :callback
end

And a controller:

defmodule MyAppWeb.AuthController do
use MyAppWeb, :controller
plug Ueberauth
def callback(%{assigns: %{ueberauth_failure: %{errors: errors}}} = conn, _params) do
conn
|> put_flash(:error, Enum.map_join(errors, ", ", & &1.message))
|> redirect(to: ~p"/")
end
def callback(%{assigns: %{ueberauth_auth: auth}} = conn, _params) do
# auth.uid => "2049151"
# auth.info.name => "David (intervals.icu)"
# auth.credentials.token => the access token, store this
# auth.credentials.scopes => ["ACTIVITY:READ", "WELLNESS:READ"]
# auth.extra.raw_info.athlete => the full athlete payload
conn
|> put_session(:athlete_id, auth.uid)
|> put_session(:intervals_icu_token, auth.credentials.token)
|> redirect(to: ~p"/")
end
end

Without Phoenix

Phoenix already does all of this for you. In a plain Plug.Router pipeline, two plugs must run beforeplug Ueberauth:

plug :fetch_session # Ueberauth's CSRF check calls fetch_session/1
plug :fetch_query_params # ...and reads conn.params["state"]
plug Ueberauth

Both are required by Ueberauth itself, in Ueberauth.Strategy.run_callback/2, which runs before this strategy is reached. Leave either out and the callback raises ArgumentError instead of failing cleanly. Swap :fetch_query_params for Plug.Parsers if you accept POST callbacks.

The cookie session store also needs conn.secret_key_base set.

examples/oauth_demo.exs is a complete, runnable pipeline — a single file you can point at a real intervals.icu application to check the whole flow end to end:

export INTERVALS_ICU_CLIENT_ID=... INTERVALS_ICU_CLIENT_SECRET=...
elixir examples/oauth_demo.exs

It prints the raw athlete payload and lists any keys the strategy does not map, which is the quickest way to see what intervals.icu actually returns for your account.

Scopes

intervals.icu joins scopes with commas, not the spaces used by most OAuth 2.0 providers:

providers: [
intervals_icu: {Ueberauth.Strategy.IntervalsIcu, [default_scope: "ACTIVITY:READ,WELLNESS:WRITE"]}
]

Each scope takes a :READ or :WRITE suffix:

ScopeCovers
ACTIVITYActivities, intervals, streams
WELLNESSWellness records: weight, HRV, resting HR, sleep
CALENDARPlanned workouts and calendar events
CHATSMessages
LIBRARYWorkout and plan library
SETTINGSAthlete profile and sport settings

The default is "ACTIVITY:READ,WELLNESS:READ,SETTINGS:READ".

SETTINGS:READ is in there because :fetch_athlete defaults to true and /api/v1/athlete/0 requires it. If you override :default_scope, either keep SETTINGS:READ or set fetch_athlete: false — see below.

You can also request scopes per request, which overrides the configured default:

/auth/intervals_icu?scope=CALENDAR:WRITE

Note: an ATHLETES scope exists but does not work with bearer tokens, where it returns 403. It only works with API keys.

Tokens do not expire, and there are no refresh tokens

This is the biggest way intervals.icu departs from a typical OAuth 2.0 provider, and it shapes how you should store credentials.

To revoke a token:

DELETE https://intervals.icu/api/v1/disconnect-app
Authorization: Bearer <token>

Calling the API

Use athlete id 0 to mean "the athlete this token belongs to":

Req.get!("https://intervals.icu/api/v1/athlete/0/activities",
auth: {:bearer, token},
params: [oldest: "2026-01-01", newest: "2026-08-12"]
)

An OAuth token only ever grants access to the authorising athlete's own data. Coaches cannot reach their athletes' data through a single connection, so each athlete has to authorise your app separately.

The athlete fetch, and when to turn it off

After exchanging the code, the strategy calls /api/v1/athlete/0 to build a fuller Ueberauth.Auth.Info, the way most Ueberauth strategies do.

That endpoint requires SETTINGS:READ — verified against the live service, which answers 403 without it. That is why the default scope includes it.

When the athlete declines

The consent screen has a checkbox per permission, so an athlete can grant activities and wellness while declining settings. When that happens the athlete endpoint answers 403, and rather than failing a login over an optional profile lookup, the strategy falls back to the id and name from the token response and logs a warning.

Authentication still succeeds, but info carries only name, with email and the rest nil. Write your callback accordingly — uid is always present, anything beyond name is best-effort:

def callback(%{assigns: %{ueberauth_auth: auth}} = conn, _params) do
# auth.uid => always present
# auth.info.name => always present
# auth.info.email => nil if the athlete declined SETTINGS
end

If you override :default_scope, either keep SETTINGS:READ in it:

providers: [
intervals_icu: {Ueberauth.Strategy.IntervalsIcu, [default_scope: "ACTIVITY:READ,SETTINGS:READ"]}
]

or skip the call entirely:

providers: [
intervals_icu: {Ueberauth.Strategy.IntervalsIcu, [fetch_athlete: false]}
]

With fetch_athlete: false no extra request is made, and the auth struct is built from the athlete map already present in the token response. You still get uid and name, but not email or the other profile fields — and the athlete is not asked to grant access to their settings, which keeps the consent screen narrower.

Note that /api/v1/athlete/0 returns roughly 160 fields — the athlete's whole settings object, including sync state for Garmin, Strava, Wahoo, Zwift and the rest. It all arrives in extra.raw_info.athlete, so take the fields you need rather than persisting the struct wholesale.

Options

OptionDefaultPurpose
:default_scope"ACTIVITY:READ,WELLNESS:READ,SETTINGS:READ"Scopes requested when no scope parameter is given
:fetch_athletetrueWhether to call the athlete endpoint after the token exchange
:userinfo_endpoint"/api/v1/athlete/0"Endpoint used when :fetch_athlete is true
:uid_field:idWhich athlete field becomes auth.uid
:oauth2_moduleUeberauth.Strategy.IntervalsIcu.OAuthModule implementing the OAuth calls

Customising HTTP behaviour

Requests go through Req. Anything under :req_options is merged into every request and overrides this library's own defaults:

config :ueberauth, Ueberauth.Strategy.IntervalsIcu.OAuth,
client_id: System.get_env("INTERVALS_ICU_CLIENT_ID"),
client_secret: System.get_env("INTERVALS_ICU_CLIENT_SECRET"),
req_options: [receive_timeout: 10_000]

Retries are off by default. Req would otherwise retry transient failures with backoff, which is the wrong trade-off during an OAuth callback: the athlete is waiting on a redirect, so seconds of backoff before an inevitable failure is worse than failing fast. The authorization code is also only valid for two minutes, so a long retry chain can consume the window it is meant to protect. Opt back in with req_options: [retry: :safe_transient].

Error keys

Failures arrive as conn.assigns.ueberauth_failure.errors, each with a message_key:

KeyMeaning
access_deniedThe athlete declined (any ?error= value is passed through under its own key)
missing_codeThe callback carried neither a code nor an error
token_errorThe token endpoint returned a non-200, most often an expired code
invalid_token_responseThe token endpoint returned 200 but no access token
tokenThe athlete endpoint returned 401
athlete_errorThe athlete endpoint returned another error status
invalid_athlete_responseThe athlete endpoint returned a success status but not a JSON object
network_errorThe request never completed
csrf_attackUeberauth's own state mismatch check

License

MIT. See LICENSE.