PersonalAccountAuthManager.java

/*
 * onedrive-java-sdk - A Java SDK to access OneDrive drives and files.
 * Copyright © 2023-2025 Andy Miles (andy.miles@amilesend.com)
 *
 * This program 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 of the License, or
 * (at your option) any later version.
 *
 * This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
 */
package com.amilesend.onedrive.connection.auth;

import com.google.common.annotations.VisibleForTesting;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NonNull;
import lombok.Setter;
import okhttp3.FormBody;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Validate;

import static com.google.common.net.HttpHeaders.CONTENT_TYPE;
import static com.google.common.net.MediaType.FORM_DATA;

/**
 * Manager that is responsible for obtaining and refreshing tokens to interact with a personal
 * OneDrive account.  Note: This does not manage the initial stages of the OAUTH request
 * flow and instead relies on a provided auth code or a pre-existing refresh token.
 * <p>Example initializing with an auth code:</p>
 * <pre>
 * PersonalAccountAuthManager.builderWithAuthCode()
 *         .httpClient(client) // the OKHttpClient instance
 *         .gsonFactory(gsonFactory) // preconfigured Gson instances
 *         .clientId(clientId) // the client ID of your application
 *         .clientSecret(clientSecret) // the client secret of your application
 *         .redirectUrl(redirectUrl) // the redirect URL for OAUTH flow
 *         .authCode(authCode) // The obtained auth code from initial OAUTH handshake
 *         .buildWithAuthCode();
 * </pre>
 * <p>Example initializing with an AuthInfo (pre-existing refresh token):</p>
 * <pre>
 * PersonalAccountAuthManager.builderWithAuthInfo()
 *         .httpClient(client)
 *         .gsonFactory(gsonFactory)
 *         .clientId(clientId)
 *         .clientSecret(clientSecret)
 *         .redirectUrl(redirectUrl)
 *         .authInfo(authInfo) // Instead of an auth code, an AuthInfo object is used to obtain the refresh token
 *         .buildWithAuthInfo();
 * </pre>
 *
 * @see OneDriveAuthInfo
 */
public class PersonalAccountAuthManager implements OneDriveAuthManager {
    private static final String PERSONAL_ENDPOINT_URL = "https://graph.microsoft.com/v1.0/me";

    private final Object lock = new Object();

    /** The client identifier. */
    @Getter(AccessLevel.PROTECTED)
    protected final String clientId;
    /** The client secret. */
    @Getter(AccessLevel.PROTECTED)
    protected final String clientSecret;
    /** The redirect URL. */
    @Getter(AccessLevel.PROTECTED)
    protected final String redirectUrl;
    /** The underlying HTTP client. */
    @Getter(AccessLevel.PROTECTED)
    protected final OkHttpClient httpClient;
    /** The GSON instance used for JSON serialization. */
    @Getter(AccessLevel.PROTECTED)
    protected final String baseTokenUrl;
    /** The current authentication information. */
    @Setter(AccessLevel.PACKAGE)
    @VisibleForTesting
    protected volatile OneDriveAuthInfo authInfo;

    /** Used to initialize and manage authentication for a given auth code. */
    @Builder(builderClassName = "BuilderWithAuthCode",
             buildMethodName = "buildWithAuthCode",
             builderMethodName = "builderWithAuthCode")
    protected PersonalAccountAuthManager(
            @NonNull final OkHttpClient httpClient,
            final String baseTokenUrl,
            final String clientId,
            final String clientSecret,
            final String redirectUrl,
            final String authCode) {
        Validate.notBlank(authCode, "authCode must not be blank");
        Validate.notBlank(clientId, "clientId must not be blank");
        Validate.notBlank(clientSecret, "clientSecret must not be blank");
        Validate.notBlank(redirectUrl, "redirectUrl must not be blank");

        this.baseTokenUrl = StringUtils.isNotBlank(baseTokenUrl) ? baseTokenUrl : AUTH_TOKEN_URL;
        this.httpClient = httpClient;
        this.clientId = clientId;
        this.clientSecret = clientSecret;
        this.redirectUrl = redirectUrl;
        this.authInfo = redeemToken(authCode);
    }

    /** Used to manage authentication for an existing AuthInfo that contains a refresh token. */
    @Builder(builderClassName = "BuilderWithAuthInfo",
             builderMethodName = "builderWithAuthInfo",
             buildMethodName = "buildWithAuthInfo")
    private PersonalAccountAuthManager(
            @NonNull final OkHttpClient httpClient,
            final String baseTokenUrl,
            final String clientId,
            final String clientSecret,
            final String redirectUrl,
            @NonNull final OneDriveAuthInfo authInfo) {
        Validate.notBlank(clientId, "clientId must not be blank");
        Validate.notBlank(clientSecret, "clientSecret must not be blank");
        Validate.notBlank(redirectUrl, "redirectUrl must not be blank");

        this.baseTokenUrl = StringUtils.isNotBlank(baseTokenUrl) ? baseTokenUrl : AUTH_TOKEN_URL;
        this.httpClient = httpClient;
        this.clientId = clientId;
        this.clientSecret = clientSecret;
        this.redirectUrl = redirectUrl;
        this.authInfo = authInfo;
        refreshToken();
    }

    @Override
    public boolean isAuthenticated() {
        return authInfo != null;
    }

    @Override
    public boolean isExpired() {
        if (!isAuthenticated()) {
            throw new AuthManagerException("Not authenticated");
        }

        return System.currentTimeMillis() >= authInfo.getExpiresIn();
    }

    @Override
    public OneDriveAuthInfo getAuthInfo() {
        refreshIfExpired();
        return authInfo;
    }

    @Override
    public String getAuthenticatedEndpoint() {
        return PERSONAL_ENDPOINT_URL;
    }

    @Override
    public OneDriveAuthInfo redeemToken(final String authCode) {
        Validate.notBlank(authCode, "authCode must not be blank");
        synchronized (lock) {
            authInfo = OneDriveAuthManager.fetchAuthInfo(httpClient, new Request.Builder()
                    .url(baseTokenUrl)
                    .header(CONTENT_TYPE, FORM_DATA.toString())
                    .post(new FormBody.Builder()
                            .add(CLIENT_ID_BODY_PARAM, clientId)
                            .add(CLIENT_SECRET_BODY_PARAM, clientSecret)
                            .add(REDIRECT_URI_BODY_PARAM, redirectUrl)
                            .add(AUTH_CODE_BODY_ARAM, authCode)
                            .add(GRANT_TYPE_BODY_PARAM, AUTH_CODE_GRANT_TYPE_BODY_PARAM_VALUE)
                            .build())
                    .build());
            return authInfo;
        }
    }

    @Override
    public OneDriveAuthInfo refreshToken() {
        synchronized (lock) {
            authInfo = OneDriveAuthManager.fetchAuthInfo(httpClient, new Request.Builder()
                    .url(baseTokenUrl)
                    .header(CONTENT_TYPE, FORM_DATA_CONTENT_TYPE)
                    .post(new FormBody.Builder()
                            .add(CLIENT_ID_BODY_PARAM, clientId)
                            .add(CLIENT_SECRET_BODY_PARAM, clientSecret)
                            .add(REDIRECT_URI_BODY_PARAM, redirectUrl)
                            .add(REFRESH_TOKEN_BODY_PARAM, authInfo.getRefreshToken())
                            .add(GRANT_TYPE_BODY_PARAM, REFRESH_TOKEN_GRANT_TYPE_BODY_PARAM_VALUE)
                            .build())
                    .build());
            return authInfo;
        }
    }
}