OAuth 2.0 Server-side Web Apps

web-server

OAuth 2.0 allows users to share specific data with an application while keeping their usernames, passwords, and other information private. For example, an application can use OAuth 2.0 to obtain permission from users to store files in their Google Drives

This OAuth 2.0 flow is specifically for user authorization. It is designed for applications that can store confidential information and maintain state. A properly authorized web server application can access an API while the user interacts with the application or after the user has left the application.

Usage

Create service account

To support server-to-server interactions, first create a [service account][service-account] for your project in the Google API Console. A service account’s credentials include a generated email address that is unique and at least one public/private key pair. If domain-wide delegation is enabled, then a client ID is also part of the service account’s credentials.

Prerequisites

Enable APIs for your project

Any application that calls Google APIs needs to enable those APIs in the API Console. To enable the appropriate APIs for your project:

  1. Open the Library page in the API Console.
  2. Select the project associated with your application. Create a project if you do not have one already.
  3. Use the Library page to find each API that your application will use. Click on each API and enable it for your project.

Create authorization credentials

Any application that uses OAuth 2.0 to access Google APIs must have authorization credentials that identify the application to Google’s OAuth 2.0 server. The following steps explain how to create credentials for your project. Your applications can then use the credentials to access APIs that you have enabled for that project.

  1. Open the Credentials page in the API Console.
  2. Click Create credentials > OAuth client ID.
  3. Complete the form. Set the application type to Web application. You must specify authorized redirect URIs. The redirect URIs are the endpoints to which the OAuth 2.0 server can send responses.

It is best that you design your app’s auth endpoints so that your application does not expose authorization codes to other resources on the page.

After creating your credentials, download the client_secret.json file from the API Console. Securely store the file in a location that only your application can access.

Important: Do not store the client_secret.json file in a publicly-accessible location. In addition, if you share the source code to your application — for example, on GitHub — store the client_secret.json file outside of your source tree to avoid inadvertently sharing your client credentials.

Using the library

Read OAuth 2.0 Client key (optional)

This step is optional. OAuth Client key, token URI, etc. can be provided in multiple ways (env vars, config, etc..). This lib offers API to read client key (client_secret.json) JSON data from file system.

Client key reader provides default implementation which uses FS2 streams in trait FS2OAuthClientKeyReader. Since it reads file from filesystem, it requires blocking execution context which is provided by extending zio.blocking.Blocking.Live module.

val oAuthClientKeyReader: ZIO[OAuthClientKeyReader, OAuthClientKeyError, OAuthClientKey] = OAuthClientKeyReader.>.readKey("/path/to/client_secret.json")
val oAuthClientKey: IO[OAuthClientKeyError, OAuthClientKey] = oAuthClientKeyReader.provide(new FS2OAuthClientKeyReader with Blocking.Live {})

Authorize

To request for an access token, you must have authorization code which is obtained by providing to user an authorization URI which will ask for his consent. The OAuth 2.0 server responds to your application’s access request by using the URL specified in the request (redirect_uri).

If the user approves the access request, then the response contains an authorization code. If the user does not approve the request, the response contains an error message. The authorization code or error message that is returned to the web server appears on the query string, as shown below:

An error response:

https://oauth2.example.com/auth?error=access_denied

An authorization code response:

https://oauth2.example.com/auth?code=4/P7q7W91a-oMsCeLvIaQm6bTrgtp7

The lib user is responsible for handling this URL callback and parsing the code/error from the request’s query param.

You generate this authorization URL with the following method:

def createAuthUrl(authApiConfig: AuthApiConfig, authRequestParams: AuthRequestParams): String

It receives two parameters, AuthApiConfig and AuthRequestParams:

/**
 * Represents config used to connect to Google OAuth 2.0 server.
 *
 * @param clientId Google OAuth 2.0 Client ID
 * @param projectId Google project ID
 * @param authUri url used for creating authorization requests (obtaining authorization code)
 * @param tokenUri url used for creating authentication requests (obtaining access and refresh tokens)
 * @param clientSecret Google client password
 * @param redirectUris List of redirect URIs, must be verified in Google Console
 */
final case class AuthApiConfig(
  clientId: String,
  projectId: String,
  authUri: String,
  tokenUri: String,
  clientSecret: String,
  redirectUris: NonEmptyList[String]
)

/**
 * Authentication request parameters. Used to construct [[AuthRequest]]
 *
 * @param scope A space-delimited list of scopes that identify the resources that your application could access on the user's behalf
 * @param redirectUri  Determines where the API server redirects the user after the user completes the authorization flow
 * @param accessType Indicates whether your application can refresh access tokens when the user is not present at the browser (online or offline)
 * @param state Specifies any string value that your application uses to maintain state between your authorization request and the authorization server's response. The server returns the exact value that you send as a name=value pair in the hash (#) fragment of the redirect_uri after the user consents to or denies your application's access request
 * @param includeGrantedScopes Enables applications to use incremental authorization to request access to additional scopes in context
 * @param loginHint The server uses the hint to simplify the login flow either by prefilling the email field in the sign-in form or by selecting the appropriate multi-login session
 * @param prompt A space-delimited, case-sensitive list of prompts to present the user. If you don't specify this parameter, the user will be prompted only the first time your app requests access. Possible values are: none, consent, select_account
 * @param responseType Response type - should be set to "code" for this type of requests
 */
final case class AuthRequestParams(
  scope: String,
  redirectUri: Option[String] = None,
  accessType: String = "offline",
  state: Option[String] = None,
  includeGrantedScopes: Option[Boolean] = None,
  loginHint: Option[String] = None,
  prompt: Option[String] = Some("consent"),
  responseType: String = "code"
)

Authenticate

Web server authentication is exposed in Authenticator module through service method

def authenticate(authApiConfig: AuthApiConfig, authorizationCode: String): ZIO[R, AuthenticationError, AccessResponse]

It receives two parameters, AuthApiConfig and authorizationCode (obtained from the previous step).

On success, authentication method returns:

/**
 * Represents Authorization Server authentication response, having both access and refresh token.
 *
 * Access token expires in one hour and can be reused until it expires.
 * @param accessToken google access token
 * @param tokenType token type
 * @param expiresAt when will token expire
 * @param refreshToken google refresh token
 */
final case class AccessResponse(
  accessToken: String,
  tokenType: String,
  expiresAt: Instant,
  refreshToken: String
)

Module contains live implementation in Authenticator.Live that depends only on org.http4s.client.Client which is needed to make http requests.

val authenticatorLiveManaged: ZManaged[Any, Throwable, Authenticator] = ZIO
  .runtime[Any]
  .toManaged_
  .flatMap { implicit rts =>
    BlazeClientBuilder[Task](rts.platform.executor.asEC)
      .resource
      .toManaged
      .map(Authenticator.Live.apply)
  }

val accessResponse: ZIO[Any, Throwable, AccessResponse] = Authenticator.>.authenticate(authApiConfig, authorizationCode).provideManaged(authenticatorLiveManaged)

Refresh an access token (offline access)

Web server access token refresh is exposed in Authenticator module through service method:

def refreshToken(authApiConfig: AuthApiConfig, refreshToken: String): ZIO[R, AuthenticationError, RefreshResponse]

It receives two parameters, AuthApiConfig and refreshToken obtained from the AccessResponse.

On success, refreshToken method returns RefreshResponse:

/**
 * Response from refresh token request.
 *
 * @param accessToken google access token
 * @param tokenType token type
 * @param expiresAt when will token expire
 */
final case class RefreshResponse(
  accessToken: String,
  tokenType: String,
  expiresAt: Instant
)

Access tokens periodically expire. You can refresh an access token without prompting the user for permission (including when the user is not present) if you requested offline access to the scopes associated with the token (default with this library). Refresh token does not expire so it is advisable to persist it to database/file system so you can use it to access a Google API when the user is not present.

To refresh the access token, you can use the same Authenticator.Live managed resource as above:

val refreshResponse: ZIO[Any, Throwable, RefreshResponse] = Authenticator.>.refreshToken(authApiConfig, accessResponseVal.refreshToken).provideManaged(authenticatorLiveManaged)
Caching

Even though OAuth 2.0 for Web Server Applications says that access tokens expire after one hour, authenticator doesn’t cache auth responses by itself. It is left for user to decide how to keep tokens and when to refresh them. Since authenticator returns authentication responses wrapped in ZIO effect, it is easy to cache them by using builtin API:

val cached: ZIO[Authenticator with clock.Clock, Nothing, IO[AuthenticationError, AccessResponse]] = Authenticator.>.authenticate(authApiConfig, authorizationCode).cached(duration.Duration(1, TimeUnit.HOURS))
Modularity

Web server API is modular and exposes these ZIO modules, Authenticator and HttpClient. With this approach users can easily switch between their own implementations or the ones that library offers.

If for example user sees value in using different http client, all that is necessary is to implement

def authenticate(request: HttpAccessRequest): ZIO[R, HttpError, HttpAccessResponse]

def refreshToken(request: HttpRefreshRequest): ZIO[R, HttpError, HttpRefreshResponse]

methods in HttpClient.Service[R] service.

Method authenticate receives parameter of type HttpAuthRequest:

final case class HttpAccessRequest(uri: String, httpAccessRequestBody: HttpAccessRequestBody)

final case class HttpAccessRequestBody(
  code: String,
  redirect_uri: String,
  client_id: String,
  client_secret: String,
  grant_type: String = "authorization_code"
)

On success it returns HttpAccessResponse:

final case class HttpAccessResponse(
  access_token: String,
  token_type: String,
  expires_in: Long,
  refresh_token: String
)

Method refreshToken receives parameter of type HttpRefreshRequest:

case class HttpRefreshRequest(uri: String, httpRefreshRequestBody: HttpRefreshRequestBody)

final case class HttpRefreshRequestBody(
  refresh_token: String,
  client_id: String,
  client_secret: String,
  grant_type: String = "refresh_token"
)

On success it returns HttpRefreshResponse:

final case class HttpRefreshResponse(
  access_token: String,
  token_type: String,
  expires_in: Long
)
Default

Authenticator module offers default implementation in trait Authenticator.Default which depends on

  • HttpClient
  • zio.clock.Clock

services.

trait Default extends Authenticator {
  self =>
  val httpClient: HttpClient.Service[Any]
  val clock: Clock.Service[Any]
  ...
}

Authenticator live implementation Authenticator.Live extends Default implementation and uses these modules:

  • Http4sClient implementation of HttpClient module
  • Clock.Live implementation of Clock module

To use Authenticator.Live implementation user needs to provide instance of org.http4s.client.Client.

Integration tests

To run integration tests together with unit tests you need to export path to client key JSON file in OAUTH_CLIENT_KEY_PATH env variable.

export OAUTH_CLIENT_KEY_PATH=/.../client_secret.json

sbt test

If OAUTH_CLIENT_KEY_PATH env variable is provided with valid client key file, the integration test will open the browser where you’ll need to consent to using the app. After consent, Google auth server will send authorization code to redirect URI that you provided in prerequisites step. When testing, integration test expects that URI to be http://localhost:8080 so it can continue with authentication and refresh token request tests.