Ü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

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".

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 may need a scope your app did not request, in which case intervals.icu answers 403 and authentication fails. If you hit that, either request SETTINGS:READ or skip the call:

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.

Options

OptionDefaultPurpose
:default_scope"ACTIVITY:READ,WELLNESS: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
forbiddenThe athlete endpoint returned 403, a scope problem; see above
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.