using System; using System.Threading.Tasks; using Newtonsoft.Json; using Unity.Services.Authentication.Models; using Unity.Services.Authentication.Utilities; using Unity.Services.Core.Internal; namespace Unity.Services.Authentication { enum AuthenticationState { SignedOut, SigningIn, VerifyingAccessToken, Authorized, Refreshing, Expired } class AuthenticationServiceInternal : IAuthenticationService { const string k_CacheKeySessionToken = "session_token"; const string k_IdProviderApple = "apple.com"; const string k_IdProviderGoogle = "google.com"; const string k_IdProviderFacebook = "facebook.com"; const string k_IdProviderSteam = "steampowered.com"; // NOTE: the REFRESH buffer should always have a larger value than the EXPIRY buffer, // i.e. it happens much earlier than the expiry time. The difference should be large // enough that the refresh process can be attempted (and retried) and succeed (or not) // before the token is actually considered expired. /// /// The buffer time in seconds to start access token refresh before the access token expires. /// const int k_AccessTokenRefreshBuffer = 120; /// /// The buffer time in seconds to treat token as expired before the token's expiry time. /// This is to deal with the time difference between the client and server. /// const int k_AccessTokenExpiryBuffer = 20; /// /// The time in seconds between access token refresh retries. /// const int k_ExpiredRefreshAttemptFrequency = 300; /// /// The max retries to get well known keys from server. /// const int k_WellKnownKeysMaxAttempt = 3; readonly IAuthenticationNetworkClient m_NetworkClient; readonly IJwtDecoder m_JwtDecoder; readonly ICache m_Cache; readonly IScheduler m_Scheduler; readonly IDateTimeWrapper m_DateTime; IWebRequest m_DelayedTokenRequest; string m_PlayerId; DateTime m_AccessTokenExpiryTime; internal event Action StateChanged; public event Action SignInFailed; public event Action SignedIn; public event Action SignedOut; public bool IsSignedIn => State == AuthenticationState.Authorized || State == AuthenticationState.Refreshing || State == AuthenticationState.Expired; public bool IsAuthorized => State == AuthenticationState.Authorized || State == AuthenticationState.Refreshing; public string AccessToken { get; private set; } public string PlayerId => // NOTE: player ID comes in with the authentication request, before the player has actually completed // the authorization process and signed in properly -- so make sure we don't accidentally expose the // player ID too early. IsSignedIn ? m_PlayerId : null; internal AuthenticationState State { get; set; } internal WellKnownKeys WellKnownKeys { get; private set; } internal AuthenticationServiceInternal(IAuthenticationNetworkClient networkClient, IJwtDecoder jwtDecoder, ICache cache, IScheduler scheduler, IDateTimeWrapper dateTime) { m_NetworkClient = networkClient; m_JwtDecoder = jwtDecoder; m_Cache = cache; m_Scheduler = scheduler; m_DateTime = dateTime; State = AuthenticationState.SignedOut; } void GetWellKnownKeys(AuthenticationAsyncOperation asyncOperation, int attempt) { if (WellKnownKeys == null) { var wellKnownKeysRequest = m_NetworkClient.GetWellKnownKeys(); wellKnownKeysRequest.Completed += resp => WellKnownKeysReceived(asyncOperation, resp, attempt); } } internal void WellKnownKeysReceived(AuthenticationAsyncOperation asyncOperation, IWebRequest response, int attempt) { try { if (response.RequestFailed) { if (attempt < k_WellKnownKeysMaxAttempt) { Logger.LogWarning($"Well-known keys request failed (attempt: {attempt}): {response.ResponseCode}, {response.ErrorMessage}"); GetWellKnownKeys(asyncOperation, attempt + 1); } else { HandleServerError(asyncOperation, response); } return; } Logger.LogVerbose("Well-known keys have arrived!"); WellKnownKeys = response.ResponseBody; if (State == AuthenticationState.VerifyingAccessToken) { CompleteSignIn(asyncOperation, m_DelayedTokenRequest.ResponseBody); } } catch (Exception e) { asyncOperation.Fail(AuthenticationError.UnknownError, "Unknown error receiving well-known keys response.", e); } } public Task SignInAnonymouslyAsync() { if (State == AuthenticationState.SignedOut) { if (!string.IsNullOrEmpty(ReadSessionToken())) { return SignInWithSessionTokenAsync(); } // I am a first-time anonymous user. return StartSigningIn(m_NetworkClient.SignInAnonymously()).AsTask(); } return AlreadySignedInError().AsTask(); } public Task SignInWithAppleAsync(string idToken) { return SignInWithExternalToken(new ExternalTokenRequest { IdProvider = k_IdProviderApple, Token = idToken }).AsTask(); } public Task LinkWithAppleAsync(string idToken) { return LinkWithExternalToken(new ExternalTokenRequest { IdProvider = k_IdProviderApple, Token = idToken }).AsTask(); } public Task SignInWithGoogleAsync(string idToken) { return SignInWithExternalToken(new ExternalTokenRequest { IdProvider = k_IdProviderGoogle, Token = idToken }).AsTask(); } public Task LinkWithGoogleAsync(string idToken) { return LinkWithExternalToken(new ExternalTokenRequest { IdProvider = k_IdProviderGoogle, Token = idToken }).AsTask(); } public Task SignInWithFacebookAsync(string accessToken) { return SignInWithExternalToken(new ExternalTokenRequest { IdProvider = k_IdProviderFacebook, Token = accessToken }).AsTask(); } public Task LinkWithFacebookAsync(string accessToken) { return LinkWithExternalToken(new ExternalTokenRequest { IdProvider = k_IdProviderFacebook, Token = accessToken }).AsTask(); } public Task SignInWithSteamAsync(string sessionTicket) { return SignInWithExternalToken(new ExternalTokenRequest { IdProvider = k_IdProviderSteam, Token = sessionTicket }).AsTask(); } public Task LinkWithSteamAsync(string sessionTicket) { return LinkWithExternalToken(new ExternalTokenRequest { IdProvider = k_IdProviderSteam, Token = sessionTicket }).AsTask(); } internal IAsyncOperation SignInWithExternalToken(ExternalTokenRequest externalToken) { if (State == AuthenticationState.SignedOut) { return StartSigningIn(m_NetworkClient.SignInWithExternalToken(externalToken)); } return AlreadySignedInError(); } internal IAsyncOperation LinkWithExternalToken(ExternalTokenRequest externalToken) { var operation = new AuthenticationAsyncOperation(); if (IsAuthorized) { var linkResult = m_NetworkClient.LinkWithExternalToken(AccessToken, externalToken); linkResult.Completed += request => LinkResponseReceived(operation, request); } else { Logger.LogWarning("The player is not authorized. Wait until authorized before attempting to link."); operation.Fail(AuthenticationError.ClientInvalidUserState); } return operation; } public Task SignInWithSessionTokenAsync() { return SignInWithSessionToken(false).AsTask(); } internal IAsyncOperation SignInWithSessionToken(bool isRefreshRequest) { var sessionToken = ReadSessionToken(); if (State == AuthenticationState.SignedOut || isRefreshRequest) { if (string.IsNullOrEmpty(sessionToken)) { return SessionTokenNotExistsError(); } Logger.LogVerbose("Continuing existing session with cached token."); return StartSigningIn(m_NetworkClient.SignInWithSessionToken(sessionToken), isRefreshRequest); } return AlreadySignedInError(); } internal string ReadSessionToken() { return m_Cache.GetString(k_CacheKeySessionToken) ?? string.Empty; } IAsyncOperation StartSigningIn(IWebRequest signInRequest, bool isRefreshRequest = false) { var asyncOp = CreateSignInAsyncOperation(); if (!isRefreshRequest) { ChangeState(AuthenticationState.SigningIn); } GetWellKnownKeys(asyncOp, 0); if (isRefreshRequest) { signInRequest.Completed += request => RefreshResponseReceived(request); } else { signInRequest.Completed += request => SignInResponseReceived(asyncOp, request); } return asyncOp; } public void SignOut() { if (State != AuthenticationState.SignedOut) { AccessToken = null; m_AccessTokenExpiryTime = default; m_PlayerId = null; m_Cache.DeleteKey(k_CacheKeySessionToken); m_Scheduler.CancelAction(ScheduledRefresh); ChangeState(AuthenticationState.SignedOut); } } internal void SignInResponseReceived(AuthenticationAsyncOperation operation, IWebRequest request) { try { if (HandleServerError(operation, request)) { return; } if (WellKnownKeys == null) { Logger.LogVerbose("Well-known keys have not arrived yet, waiting on them to complete sign-in."); m_DelayedTokenRequest = request; ChangeState(AuthenticationState.VerifyingAccessToken); // operation will complete in WellKnownKeysReceived() } else { CompleteSignIn(operation, request.ResponseBody); } } catch (Exception e) { operation.Fail(AuthenticationError.UnknownError, "Unknown error receiving SignIn response.", e); } } internal void LinkResponseReceived(AuthenticationAsyncOperation operation, IWebRequest request) { try { if (HandleServerError(operation, request)) { return; } // The data in the response of link can be discarded. We already have all information in current context. // Just mark it as succeed to notify caller that the user is linked successfully. operation.Succeed(); } catch (Exception e) { operation.Fail(AuthenticationError.UnknownError, "Unknown error receiving link response.", e); } } void CompleteSignIn(AuthenticationAsyncOperation operation, SignInResponse response) { try { var accessTokenDecoded = m_JwtDecoder.Decode(response.IdToken, WellKnownKeys); if (accessTokenDecoded == null) { operation.Fail(AuthenticationError.InvalidAccessToken, "Failed to decode and verify access token."); } else { AccessToken = response.IdToken; m_PlayerId = response.UserId; m_Cache.SetString(k_CacheKeySessionToken, response.SessionToken); var expiry = response.ExpiresIn > k_AccessTokenRefreshBuffer ? response.ExpiresIn - k_AccessTokenRefreshBuffer : response.ExpiresIn; m_AccessTokenExpiryTime = m_DateTime.UtcNow.AddSeconds(response.ExpiresIn - k_AccessTokenExpiryBuffer); m_Scheduler.ScheduleAction(ScheduledRefresh, expiry); ChangeState(AuthenticationState.Authorized); operation.Succeed(); } } catch (Exception e) { operation.Fail(AuthenticationError.UnknownError, "Unknown error completing sign-in.", e); } } internal void ScheduledRefresh() { // If we have just been unpaused, Unity's execution order may mean that this triggers // the refresh process when in fact the Expiry process should take hold (i.e. the scheduler executes // this action before the OnApplicationPause callback). So to ensure we don't double the refresh // request, check the token hasn't expired before triggering refresh. if (m_AccessTokenExpiryTime > m_DateTime.UtcNow) { RefreshAccessToken(); } } internal void RefreshAccessToken() { if (IsSignedIn) { Logger.LogVerbose("Making token refresh request..."); if (State != AuthenticationState.Expired) { ChangeState(AuthenticationState.Refreshing); } SignInWithSessionToken(true); } } internal void RefreshResponseReceived(IWebRequest request) { if (request.RequestFailed) { Logger.LogError($"Request failed: {request.ResponseCode}, {request.ErrorMessage}"); // NOTE: depending on how long it took to fail, this might occur before the access token has _actually_ expired. // This is likely safer than risking an expired access token reaching a consuming service. if (request.ServerError && request.ResponseCode < 500) { Logger.LogWarning("Failed to refresh access token due to server error, signing out."); SignOut(); } else { Logger.LogWarning("Failed to refresh access token due to network error or internal server error, will retry later."); m_Scheduler.ScheduleAction(RefreshAccessToken, k_ExpiredRefreshAttemptFrequency); if (State != AuthenticationState.Expired) { Expire(); } } } else { var asyncOp = new AuthenticationAsyncOperation(); CompleteSignIn(asyncOp, request.ResponseBody); if (asyncOp.Status == AsyncOperationStatus.Succeeded) { Logger.LogVerbose("Refresh complete!"); } else { Logger.LogWarning("The access token is not valid. Retry JWKS and refresh again."); // Refresh failed since we received a bad token. Retry. m_Scheduler.ScheduleAction(RefreshAccessToken, k_ExpiredRefreshAttemptFrequency); } } } public void ApplicationUnpaused() { if (IsAuthorized && m_DateTime.UtcNow > m_AccessTokenExpiryTime) { Logger.LogVerbose("Application unpause found the access token to have expired already."); Expire(); RefreshAccessToken(); } } void Expire() { AccessToken = null; m_AccessTokenExpiryTime = default; ChangeState(AuthenticationState.Expired); } void ChangeState(AuthenticationState newState) { // NOTE: always call this at the end of a method where state is changed, so that any consumer // that has subscribed to the event will get the correct data for the new state. Logger.LogVerbose($"Moved from state [{State}] to [{newState}]"); var oldState = State; State = newState; HandleStateChanged(oldState, newState); } void HandleStateChanged(AuthenticationState oldState, AuthenticationState newState) { StateChanged?.Invoke(oldState, newState); switch (newState) { case AuthenticationState.Authorized: if (oldState != AuthenticationState.Refreshing && oldState != AuthenticationState.Expired) { SignedIn?.Invoke(); } break; case AuthenticationState.SignedOut: SignedOut?.Invoke(); break; } } /// /// Create that always fail with ClientInvalidUserState error /// when the user is calling SignIn* methods while not in SignedOut state. /// /// The exception that represents the error. IAsyncOperation AlreadySignedInError() { var exception = new AuthenticationException( AuthenticationError.ClientInvalidUserState, "The player is already signed in. Sign out before attempting to sign in again."); var asyncOp = new AuthenticationAsyncOperation(); asyncOp.Fail(exception); return asyncOp; } /// /// Create that always fail with ClientNoActiveSession error /// when the user is calling SignInWithSessionToken methods while there is no session token stored. /// /// The exception that represents the error. IAsyncOperation SessionTokenNotExistsError() { var exception = new AuthenticationException( AuthenticationError.ClientNoActiveSession, "There is no cached session token."); // At this point, the contents of the cache are invalid, and we don't want future // SignInAnonymously or SignInWithSessionToken to read the current contents of the key. m_Cache.DeleteKey(k_CacheKeySessionToken); var asyncOp = new AuthenticationAsyncOperation(); asyncOp.Fail(exception); return asyncOp; } /// /// Handles the error from server. If request is not failed, do nothing. Otherwise try to parse the error /// and call operation.Fail. Caller shall check operation.Status before moving forward. /// /// The async operation to mark failure in case of server error. /// The web request to parse error. /// The type parameter of web request. In case of an error it is not used. /// Whether there is an error occurred. bool HandleServerError(AuthenticationAsyncOperation operation, IWebRequest request) { if (!request.RequestFailed) { return false; } Logger.LogError($"Request failed: {request.ResponseCode}, {request.ErrorMessage}"); if (request.NetworkError) { operation.Fail(AuthenticationError.NetworkError); return true; } // otherwise it's a server error. Try to parse the error. try { var errorResponse = JsonConvert.DeserializeObject(request.ErrorMessage); operation.Fail(errorResponse.Title, errorResponse.Detail); } catch (JsonException ex) { operation.Fail( AuthenticationError.UnknownError, "Failed to deserialize server response.", ex); } catch (Exception ex) { operation.Fail( AuthenticationError.UnknownError, "Unknown error deserializing server response. ", ex); } return true; } /// /// Create the AsyncOperation that will sign-out in case of failure. /// internal AuthenticationAsyncOperation CreateSignInAsyncOperation() { var asyncOp = new AuthenticationAsyncOperation(); asyncOp.BeforeFail += SendSignInFailedEvent; return asyncOp; } void SendSignInFailedEvent(AuthenticationAsyncOperation operation) { SignInFailed?.Invoke(operation.Exception); SignOut(); } } }