Ueberauth strategy for authenticating athletes with intervals.icu.
Setup
Register an application at https://intervals.icu/settings to obtain a client id and secret, and register your exact callback URL. intervals.icu does not support wildcards in redirect URIs. Every callback URL must be registered in full.
Add the provider to your Ueberauth configuration:
config :ueberauth, Ueberauth,
providers: [
intervals_icu: {Ueberauth.Strategy.IntervalsIcu, [default_scope: "ACTIVITY:READ"]}
]
config :ueberauth, Ueberauth.Strategy.IntervalsIcu.OAuth,
client_id: System.get_env("INTERVALS_ICU_CLIENT_ID"),
client_secret: System.get_env("INTERVALS_ICU_CLIENT_SECRET")Options
:default_scope- scopes requested when the request phase does not receive ascopequery parameter. Defaults to"ACTIVITY:READ,WELLNESS:READ,SETTINGS:READ".SETTINGS:READis included because:fetch_athletedefaults to true and the athlete endpoint requires it; see "Fetching the athlete" below.:fetch_athlete- whether to call the athlete endpoint after the token exchange to build a fullerUeberauth.Auth.Info. Defaults totrue. See "Fetching the athlete" below.:userinfo_endpoint- the endpoint used when:fetch_athleteis true. Defaults to"/api/v1/athlete/0". The athlete id0always means "the athlete this token belongs to".:uid_field- which athlete field becomesUeberauth.Auth.uid. Defaults to:id.:oauth2_module- the module implementing the OAuth calls. Defaults toUeberauth.Strategy.IntervalsIcu.OAuth.
Scopes
intervals.icu scopes are ACTIVITY, WELLNESS, CALENDAR, CHATS,
LIBRARY and SETTINGS, each suffixed with :READ or :WRITE, and joined
with commas rather than the spaces used by most OAuth 2.0 providers:
default_scope: "ACTIVITY:READ,WELLNESS:WRITE"Fetching the athlete
The token response already contains the athlete's id and name, so this strategy can identify the athlete without any further request.
By default it still calls :userinfo_endpoint to populate a richer
Ueberauth.Auth.Info, mirroring how most Ueberauth strategies behave.
That endpoint requires SETTINGS:READ, which 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. If that happens the athlete
endpoint answers 403, and rather than failing a login over an optional
profile lookup, this strategy falls back to the id and name already in the
token response and logs a warning.
So authentication still succeeds, but info carries only name, with
email and the rest nil. Design your callback for that possibility: uid
is always present, everything beyond name is best-effort.
If you override :default_scope, either keep SETTINGS:READ in it:
default_scope: "ACTIVITY:READ,SETTINGS:READ"or turn the athlete fetch off, which also silences the warning:
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 inside the token response. You get uid and
name, but not email or the other profile fields — and the athlete is not
asked to grant access to their settings.
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.
Tokens do not expire, and there are no refresh tokens
intervals.icu issues no refresh token and no expiry, so
Ueberauth.Auth.Credentials always comes back with refresh_token: nil,
expires: false and expires_at: nil. Store the access token and use it
until it stops working; to recover, send the athlete through the flow again.
A token can be revoked with:
DELETE https://intervals.icu/api/v1/disconnect-app
Authorization: Bearer <token>Plain Plug pipelines
Under Phoenix everything below is already handled. In a bare Plug.Router
pipeline, two plugs must run before plug Ueberauth:
plug :fetch_session # Ueberauth's CSRF check calls fetch_session/1
plug :fetch_query_params # ...and reads conn.params["state"]
plug UeberauthBoth are required by Ueberauth itself, in Ueberauth.Strategy.run_callback/2,
which runs before this strategy is reached. Without them the callback phase
raises ArgumentError rather than failing cleanly. Use Plug.Parsers in
place of :fetch_query_params if you accept POST callbacks.
This strategy additionally calls Plug.Conn.fetch_query_params/1 in both
phases, so it behaves correctly even when the CSRF check is bypassed with
ignores_csrf_attack: true.
See examples/oauth_demo.exs in the repository for a complete working
pipeline.
Summary
Functions
The token, its type and its granted scopes.
Callback implementation for Ueberauth.Strategy.default_options/0.
The raw token and athlete payloads, for anything this strategy does not map.
Handles the redirect back from intervals.icu.
Removes the data this strategy stored on the connection.
Redirects the athlete to the intervals.icu authorize endpoint.
The athlete's profile information.
The athlete's unique id, taken from the field named by :uid_field.
Functions
The token, its type and its granted scopes.
refresh_token, expires and expires_at are always nil/false, because
intervals.icu issues neither refresh tokens nor expiring access tokens.
Callback implementation for Ueberauth.Strategy.default_options/0.
The raw token and athlete payloads, for anything this strategy does not map.
Handles the redirect back from intervals.icu.
On success the token is exchanged and, unless :fetch_athlete is disabled,
the athlete is fetched. A denial arrives as ?error=access_denied.
Removes the data this strategy stored on the connection.
Redirects the athlete to the intervals.icu authorize endpoint.
A scope query parameter on the request overrides :default_scope, letting
a single provider ask for different scopes per request.
The athlete's profile information.
Only :name is guaranteed, because it is present in the token response. The
remaining fields depend on what the athlete endpoint returns and are nil
when absent. The complete, untouched athlete map is always available via
extra/1.
The athlete's unique id, taken from the field named by :uid_field.