SumUpAuth.kt

/*
 * This file is part of GNU Taler
 * (C) 2026 Taler Systems S.A.
 *
 * GNU Taler is free software; you can redistribute it and/or modify it under the
 * terms of the GNU General Public License as published by the Free Software
 * Foundation; either version 3, or (at your option) any later version.
 *
 * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
 * A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along with
 * GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
 */

package net.taler.merchantpos.paymentoptions.sumup

import android.content.Context
import android.net.Uri
import android.os.Looper

import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData

import net.taler.common.getDefaultHttpClient

import java.util.UUID
import java.util.concurrent.TimeUnit

import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.ResponseException
import io.ktor.client.request.forms.submitForm
import io.ktor.client.statement.bodyAsText
import io.ktor.http.Parameters
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

internal const val SUMUP_AUTHORIZE_URL = "https://api.sumup.com/authorize"
internal const val SUMUP_TOKEN_URL = "https://api.sumup.com/token"

/** Registered as the OAuth redirect URI; the app already owns the taler-pos scheme. */
internal const val SUMUP_REDIRECT_HOST = "sumup-oauth"
internal const val SUMUP_REDIRECT_URI = "taler-pos://$SUMUP_REDIRECT_HOST"

/**
 * Scopes requested when a merchant authorizes this installation.
 *
 * Deliberately only the ones a merchant can grant themselves. SumUp gates `payments`
 * and `payment_instruments` behind a manual review, so asking for them here would
 * block every merchant on a support ticket; they are only needed to actually charge
 * a card, not to hold a session and manage a reader, so they are added once granted.
 * `user.profile_readonly` is enough to read the merchant code the screen shows.
 */
internal const val SUMUP_SCOPES =
    "user.profile_readonly transactions.history readers.read readers.write"

/** Refreshed this long before the access token actually expires. */
private val REFRESH_MARGIN_MS = TimeUnit.MINUTES.toMillis(15)

internal data class TokenSet(
    val accessToken: String,
    val refreshToken: String,
    val expiresAt: Long,
)

/** Distinguishes a lapsed grant, which needs the merchant, from a network blip, which does not. */
internal sealed interface TokenResult
{
    /** Tokens returned by a successful authorization or refresh. */
    data class Success(
        val tokens: TokenSet,
    ) : TokenResult

    /** The refresh token is no longer usable: the merchant has to authorize again. */
    data object GrantInvalid : TokenResult

    /** The request failed without an explicit rejection of the authorization grant. */
    data class Failed(
        val message: String,
    ) : TokenResult
}

internal interface TokenEndpoint
{
    suspend fun exchangeCode(
        clientId: String,
        clientSecret: String,
        code: String,
    ): TokenResult

    suspend fun refresh(
        clientId: String,
        clientSecret: String,
        refreshToken: String,
    ): TokenResult
}

@Serializable
private data class TokenResponse(
    @SerialName("access_token") val accessToken: String,
    @SerialName("refresh_token") val refreshToken: String? = null,
    @SerialName("expires_in") val expiresIn: Long = 3600,
)

internal class SumUpTokenEndpoint(
    private val client: HttpClient = getDefaultHttpClient(followRedirect = true),
    private val now: () -> Long = System::currentTimeMillis,
) : TokenEndpoint
{
    override suspend fun exchangeCode(
        clientId: String,
        clientSecret: String,
        code: String,
    ) = post(
        Parameters.build {
            append("grant_type", "authorization_code")
            append("client_id", clientId)
            append("client_secret", clientSecret)
            append("code", code)
            append("redirect_uri", SUMUP_REDIRECT_URI)
        },
        previousRefreshToken = null,
    )

    override suspend fun refresh(
        clientId: String,
        clientSecret: String,
        refreshToken: String,
    ) = post(
        Parameters.build {
            append("grant_type", "refresh_token")
            append("client_id", clientId)
            append("client_secret", clientSecret)
            append("refresh_token", refreshToken)
        },
        previousRefreshToken = refreshToken,
    )

    private suspend fun post(
        form: Parameters,
        previousRefreshToken: String?,
    ): TokenResult =
        try
        {
            val response: TokenResponse = client.submitForm(SUMUP_TOKEN_URL, form).body()
            TokenResult.Success(
                TokenSet(
                    accessToken = response.accessToken,
                    // SumUp rotates refresh tokens, so keep the newest one it hands back.
                    refreshToken = response.refreshToken ?: previousRefreshToken.orEmpty(),
                    expiresAt = now() + TimeUnit.SECONDS.toMillis(response.expiresIn),
                ),
            )
        }
        catch (e: CancellationException)
        {
            throw e
        }
        catch (e: ResponseException)
        {
            val body =
                try
                {
                    e.response.bodyAsText()
                }
                catch (cancelled: CancellationException)
                {
                    throw cancelled
                }
                catch (_: Exception)
                {
                    ""
                }
            if ("invalid_grant" in body)
            {
                TokenResult.GrantInvalid
            }
            else
            {
                TokenResult.Failed("${e.response.status.value}: ${body.take(200)}")
            }
        }
        catch (e: Exception)
        {
            TokenResult.Failed(e.message ?: e.javaClass.simpleName)
        }
}

data class SumUpAuthState(
    val hasClientCredentials: Boolean = false,
    val authorized: Boolean = false,
    val expiresAt: Long = 0,
    val lastRefresh: Long = 0,
    val refreshCount: Int = 0,
    val lastError: String? = null,
)

/**
 * Renews the SumUp session after the merchant authorizes this installation.
 *
 * Refresh tokens rotate and are stored for the next renewal. A rejected grant clears
 * the tokens so the merchant can authorize again; transient failures retain them.
 */
internal class SumUpAuth(
    context: Context,
    private val endpoint: TokenEndpoint = SumUpTokenEndpoint(),
    private val now: () -> Long = System::currentTimeMillis,
)
{
    private val preferences = context.getSharedPreferences("payment-terminals", Context.MODE_PRIVATE)
    private val tokenMutex = Mutex()
    private var authorizationGeneration = 0L
    private val mutableState = MutableLiveData(SumUpAuthState())

    private val clientSecret: String
        get() = preferences.getString(KEY_CLIENT_SECRET, "").orEmpty()

    private val accessToken: String? get() = preferences.getString(KEY_ACCESS_TOKEN, null)

    private val expiresAt: Long get() = preferences.getLong(KEY_EXPIRES_AT, 0)

    val state: LiveData<SumUpAuthState> = mutableState

    var clientId: String
        get() = preferences.getString(KEY_CLIENT_ID, "").orEmpty()
        private set(value) = preferences.edit().putString(KEY_CLIENT_ID, value).apply()

    val refreshToken: String? get() = preferences.getString(KEY_REFRESH_TOKEN, null)

    init
    {
        publish()
    }

    fun hasClientCredentials() = clientId.isNotBlank() && clientSecret.isNotBlank()

    fun isAuthorized() = !refreshToken.isNullOrBlank()

    fun setClientCredentials(
        id: String,
        secret: String,
    )
    {
        clearAuthorization()
        preferences
            .edit()
            .remove(KEY_PENDING_STATE)
            .putString(KEY_CLIENT_ID, id.trim())
            .putString(KEY_CLIENT_SECRET, secret.trim())
            .apply()
        publish()
    }

    /** Starts one authorization attempt, retaining its nonce across activity recreation. */
    fun authorizeUrl(): String
    {
        val state = UUID.randomUUID().toString()
        check(preferences.edit().putString(KEY_PENDING_STATE, state).commit())
        return Uri
            .parse(SUMUP_AUTHORIZE_URL)
            .buildUpon()
            .appendQueryParameter("response_type", "code")
            .appendQueryParameter("client_id", clientId)
            .appendQueryParameter("redirect_uri", SUMUP_REDIRECT_URI)
            .appendQueryParameter("scope", SUMUP_SCOPES)
            .appendQueryParameter("state", state)
            .build()
            .toString()
    }

    /** Consume before exchanging the code, so unsolicited and replayed redirects cannot sign in. */
    fun consumeAuthorizationState(state: String?): Boolean
    {
        val expected = preferences.getString(KEY_PENDING_STATE, null) ?: return false
        if (state != expected) return false
        return preferences.edit().remove(KEY_PENDING_STATE).commit()
    }

    suspend fun exchangeCode(code: String): TokenResult
    {
        val generation = authorizationGeneration
        return tokenMutex.withLock {
            if (generation != authorizationGeneration ||
                !hasClientCredentials())
            {
                return@withLock TokenResult.Failed("OAuth authorization changed")
            }
            val result = endpoint.exchangeCode(clientId, clientSecret, code)
            storeIfCurrent(generation, result)
        }
    }

    /** A token still valid right now, or null when one has to be fetched first. */
    fun validAccessToken(): String? = accessToken?.takeIf { it.isNotBlank() && expiresAt > now() }

    /**
     * Buys a new access token when the current one is close to expiring. Returns null
     * when there is nothing to do, so a caller can tell "fine" from "tried and failed".
     */
    suspend fun refreshIfNeeded(force: Boolean = false): TokenResult?
    {
        val generation = authorizationGeneration
        // Refresh tokens rotate: concurrent callers must use the previous call's new token.
        return tokenMutex.withLock {
            if (generation != authorizationGeneration) return@withLock null
            val token = refreshToken
            if (!hasClientCredentials() ||
                token.isNullOrBlank())
            {
                return@withLock null
            }
            if (!force &&
                expiresAt - now() > REFRESH_MARGIN_MS)
            {
                return@withLock null
            }
            val result = endpoint.refresh(clientId, clientSecret, token)
            storeIfCurrent(generation, result)
        }
    }

    private fun storeIfCurrent(
        generation: Long,
        result: TokenResult,
    ): TokenResult
    {
        // A response arriving after logout must never restore the old merchant's tokens.
        if (generation != authorizationGeneration) return TokenResult.Failed("OAuth authorization changed")
        store(result)
        return result
    }

    private fun store(result: TokenResult)
    {
        val editor = preferences.edit()
        when (result)
        {
            is TokenResult.Success ->
            {
                editor
                    .putString(KEY_ACCESS_TOKEN, result.tokens.accessToken)
                    .putString(KEY_REFRESH_TOKEN, result.tokens.refreshToken)
                    .putLong(KEY_EXPIRES_AT, result.tokens.expiresAt)
                    .putLong(KEY_LAST_REFRESH, now())
                    .putInt(KEY_REFRESH_COUNT, preferences.getInt(KEY_REFRESH_COUNT, 0) + 1)
                    .remove(KEY_LAST_ERROR)
            }

            // The grant is gone, so the stored tokens are worthless: drop them rather
            // than retry forever, and let the screen ask for a new authorization.
            TokenResult.GrantInvalid ->
            {
                editor
                    .remove(KEY_ACCESS_TOKEN)
                    .remove(KEY_REFRESH_TOKEN)
                    .remove(KEY_EXPIRES_AT)
                    .putString(KEY_LAST_ERROR, "invalid_grant")
            }

            is TokenResult.Failed ->
            {
                editor.putString(KEY_LAST_ERROR, result.message)
            }
        }
        editor.apply()
        publish()
    }

    /** Forgets the authorization but keeps the client credentials for a new one. */
    fun clearAuthorization()
    {
        authorizationGeneration++
        preferences
            .edit()
            .remove(KEY_PENDING_STATE)
            .remove(KEY_ACCESS_TOKEN)
            .remove(KEY_REFRESH_TOKEN)
            .remove(KEY_EXPIRES_AT)
            .remove(KEY_LAST_REFRESH)
            .remove(KEY_REFRESH_COUNT)
            .remove(KEY_LAST_ERROR)
            .apply()
        publish()
    }

    fun clearAll()
    {
        preferences
            .edit()
            .remove(KEY_CLIENT_ID)
            .remove(KEY_CLIENT_SECRET)
            .apply()
        clearAuthorization()
    }

    private fun publish()
    {
        val updated =
            SumUpAuthState(
                hasClientCredentials = hasClientCredentials(),
                authorized = isAuthorized(),
                expiresAt = expiresAt,
                lastRefresh = preferences.getLong(KEY_LAST_REFRESH, 0),
                refreshCount = preferences.getInt(KEY_REFRESH_COUNT, 0),
                lastError = preferences.getString(KEY_LAST_ERROR, null),
            )
        if (mutableState.value == updated) return
        // Publish synchronously on the main thread so callers can read the new state;
        // otherwise enqueue the update for the main looper.
        if (Looper.myLooper() == Looper.getMainLooper())
        {
            mutableState.value = updated
        }
        else
        {
            mutableState.postValue(updated)
        }
    }

    private companion object
    {
        const val KEY_PENDING_STATE = "sumup.oauth.pending-state"
        const val KEY_CLIENT_ID = "sumup.oauth.client-id"
        const val KEY_CLIENT_SECRET = "sumup.oauth.client-secret"
        const val KEY_ACCESS_TOKEN = "sumup.oauth.access-token"
        const val KEY_REFRESH_TOKEN = "sumup.oauth.refresh-token"
        const val KEY_EXPIRES_AT = "sumup.oauth.expires-at"
        const val KEY_LAST_REFRESH = "sumup.oauth.last-refresh"
        const val KEY_REFRESH_COUNT = "sumup.oauth.refresh-count"
        const val KEY_LAST_ERROR = "sumup.oauth.last-error"
    }
}