Ueberauth.Strategy.IntervalsIcu (Ueberauth intervals.icu v0.1.2)

Copy Markdown View Source

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 a scope query parameter. Defaults to "ACTIVITY:READ,WELLNESS:READ,SETTINGS:READ". SETTINGS:READ is included because :fetch_athlete defaults 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 fuller Ueberauth.Auth.Info. Defaults to true. See "Fetching the athlete" below.

  • :userinfo_endpoint - the endpoint used when :fetch_athlete is true. Defaults to "/api/v1/athlete/0". The athlete id 0 always means "the athlete this token belongs to".

  • :uid_field - which athlete field becomes Ueberauth.Auth.uid. Defaults to :id.

  • :oauth2_module - the module implementing the OAuth calls. Defaults to Ueberauth.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 Ueberauth

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

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

credentials(conn)

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.

default_options()

Callback implementation for Ueberauth.Strategy.default_options/0.

extra(conn)

The raw token and athlete payloads, for anything this strategy does not map.

handle_callback!(conn)

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.

handle_cleanup!(conn)

Removes the data this strategy stored on the connection.

handle_request!(conn)

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.

info(conn)

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.

uid(conn)

The athlete's unique id, taken from the field named by :uid_field.