defmodule Immich.API.OAuth do
@moduledoc """
OAuth authentication flow for the Immich API.
This module provides the two-step authorization code flow used by Immich:
1. `authorize/2` starts the flow and returns a remote login URL.
2. `callback/3` completes the flow after the browser redirect and builds
an `Immich.API.Session`.
The module is intentionally stateless. Callers are responsible for storing
the returned OAuth context between steps.
## Shared options
Most functions accept `shared_opts`:
- `:base_url` (required) - Immich server URL (for example
`"https://demo.immich.app"`)
- `:client` (optional) - HTTP client module implementing
`Immich.API.Client.Behaviour` (defaults to `Immich.API.Client`)
## Usage
```elixir
alias Immich.Api.OAuth
opts = [base_url: "https://demo.immich.app"]
# Step 1: Start flow
{:ok, login_url, oauth_context} =
OAuth.authorize("http://localhost:55555/oauth-callback", opts)
# Open login_url in browser, then receive callback URL in your app.
callback_url = "http://localhost:55555/oauth-callback?code=...&state=..."
# Step 2: Complete flow
{:ok, session} = OAuth.callback(callback_url, oauth_context, opts)
# session.access_token can now be used for authenticated API calls.
```
## Error handling
Functions return `{:error, reason}` tuples for API-level failures, including
transport/authentication errors from the configured client and unexpected
successful payloads (`{:unexpected_response, payload}`).
"""
alias Immich.API.Client
alias Immich.API.PKCE
alias Immich.API.Session
@typedoc "Immich server base URL without the trailing /api (for example, https://demo.immich.app)"
@type base_url :: String.t()
@typedoc "Redirect URI sent to the authorize endpoint."
@type redirect_uri :: String.t()
@typedoc "Callback URI received after OAuth login."
@type callback_uri :: String.t()
@typedoc "Login URL returned by authorize."
@type login_url :: String.t()
@typedoc "Error tuple returned by OAuth functions."
@type oauth_error :: Client.api_error() | {:unexpected_response, term()}
@typedoc "Shared options for OAuth requests."
@type shared_opts :: [
base_url: base_url(),
client: Client.t()
]
@type oauth_context :: %{
pkce: PKCE.t()
}
@doc """
Starts OAuth login
This function starts the OAuth login flow and returns the remote login URL
authorization context that needs to be passed to callback/3.
"""
@spec authorize(redirect_uri(), shared_opts()) ::
{:ok, login_url(), oauth_context()} | {:error, oauth_error()}
def authorize(redirect_uri, opts) do
pkce = PKCE.new()
request = %{
"redirectUri" => redirect_uri,
"codeChallenge" => pkce.code_challenge,
"state" => pkce.state
}
case api_post("/api/oauth/authorize", request, opts) do
{:ok, %{"url" => url}} ->
{:ok, url, %{pkce: pkce}}
{:ok, payload} ->
{:error, {:unexpected_response, payload}}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Completes OAuth callback exchange and returns an authenticated session.
This function should be called after the user has been redirected back to
your application from the OAuth provider.
"""
@spec callback(callback_uri(), oauth_context(), shared_opts()) ::
{:ok, Session.t()} | {:error, oauth_error()}
def callback(callback_url, oauth_context, opts) do
pkce = oauth_context.pkce
request = %{
"url" => callback_url,
"state" => pkce.state,
"codeVerifier" => pkce.code_verifier
}
case api_post("/api/oauth/callback", request, opts) do
{:ok, %{"accessToken" => access_token}} ->
session = build_session(access_token, opts)
{:ok, session}
{:ok, payload} ->
{:error, {:unexpected_response, payload}}
{:error, reason} ->
{:error, reason}
end
end
defp api_post(path, request, opts) do
client = client(opts)
path
|> build_url(opts)
|> client.post(request, [])
end
defp client(opts) do
Keyword.get(opts, :client, Immich.API.Client)
end
defp build_url(path, opts) do
opts
|> Access.fetch!(:base_url)
|> URI.merge(path)
end
defp build_session(access_token, opts) do
base_url = Access.fetch!(opts, :base_url)
%Session{base_url: base_url, access_token: access_token}
end
end