This is the second walkthrough in the Classroom add-ons walkthrough series.
In this walkthrough, you add Google Sign-in to the web application. This is a required behavior for Classroom add-ons. Use the credentials from this authorization flow for all future calls to the API.
In the course of this walkthrough, you complete the following:
- Configure your web app to maintain session data within an iframe.
- Implement Google OAuth 2.0 server-to-server sign in flow.
- Issue a call to the OAuth 2.0 API.
- Create additional routes to support authorizing, signing out, and testing API calls.
Once finished, you can fully authorize users in your web app and issue calls to Google APIs.
Understand the authorization flow
Google APIs use the OAuth 2.0 protocol for authentication and authorization. The full description of Google's OAuth implementation is available in the Google Identity OAuth guide.
Your application's credentials are managed in Google Cloud. Once these have been created, implement a four-step process to authenticate and authorize a user:
- Request authorization. Provide a callback URL as part of this request. When complete, you receive an authorization URL.
- Redirect the user to the authorization URL. The resulting page informs the user of the permissions your app requires, and prompts them to allow access. When complete, the user is routed to the callback URL.
- Receive an authorization code at your callback route. Exchange the authorization code for an access token and a refresh token.
- Make calls to a Google API using the tokens.
Obtain OAuth 2.0 credentials
Ensure that you have created and downloaded OAuth credentials as described in the Overview page. Your project must use these credentials to sign in the user.
Implement the authorization flow
Add logic and routes to our web app to realize the described flow, including these features:
- Initiate the authorization flow upon reaching the landing page.
- Request authorization and handle the authorization server response.
- Clear the stored credentials.
- Revoke the app's permissions.
- Test an API call.
Initiate authorization
Modify your landing page to initiate the authorization flow if necessary. The add-on can be in two possible states; either there are saved tokens in the current session, or you need to obtain tokens from the OAuth 2.0 server. Perform a test API call if there are tokens in the session, or otherwise prompt the user to sign in.
Python
Open your routes.py
file. First set a couple of constants and our cookie
configuration per the iframe security recommendations.
# The file that contains the OAuth 2.0 client_id and client_secret.
CLIENT_SECRETS_FILE = "client_secret.json"
# The OAuth 2.0 access scopes to request.
# These scopes must match the scopes in your Google Cloud project's OAuth Consent
# Screen: https://console.cloud.google.com/apis/credentials/consent
SCOPES = [
"openid",
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/classroom.addons.teacher",
"https://www.googleapis.com/auth/classroom.addons.student"
]
# Flask cookie configurations.
app.config.update(
SESSION_COOKIE_SECURE=True,
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="None",
)
Move to your add-on landing route (this is /classroom-addon
in the example
file). Add logic to render a sign-in page if the session doesn't contain
the "credentials" key.
@app.route("/classroom-addon")
def classroom_addon():
if "credentials" not in flask.session:
return flask.render_template("authorization.html")
return flask.render_template(
"addon-discovery.html",
message="You've reached the addon discovery page.")
Java
The code for this walkthrough can be found in the step_02_sign_in
module.
Open the application.properties
file, and add session configuration that
follows the iframe security recommendations.
# iFrame security recommendations call for cookies to have the HttpOnly and
# secure attribute set
server.servlet.session.cookie.http-only=true
server.servlet.session.cookie.secure=true
# Ensures that the session is maintained across the iframe and sign-in pop-up.
server.servlet.session.cookie.same-site=none
Create a service class (AuthService.java
in the step_02_sign_in
module)
to handle the logic behind the endpoints in the controller file and set up
the redirect URI, client secrets file location, and scopes that your add-on
requires. The redirect URI is used to reroute your users to a specific URI
after they authorize your app. See the Project Set Up section of the
README.md
in the source code for information on where to place your
client_secret.json
file.
@Service
public class AuthService {
private static final String REDIRECT_URI = "https://localhost:5000/callback";
private static final String CLIENT_SECRET_FILE = "client_secret.json";
private static final HttpTransport HTTP_TRANSPORT = new NetHttpTransport();
private static final JsonFactory JSON_FACTORY = GsonFactory.getDefaultInstance();
private static final String[] REQUIRED_SCOPES = {
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/classroom.addons.teacher",
"https://www.googleapis.com/auth/classroom.addons.student"
};
/** Creates and returns a Collection object with all requested scopes.
* @return Collection of scopes requested by the application.
*/
public static Collection<String> getScopes() {
return new ArrayList<>(Arrays.asList(REQUIRED_SCOPES));
}
}
Open the controller file (AuthController.java
in the step_02_sign_in
module) and add logic to the landing route to render the sign-in page if the
session doesn't contain the credentials
key.
@GetMapping(value = {"/start-auth-flow"})
public String startAuthFlow(Model model) {
try {
return "authorization";
} catch (Exception e) {
return onError(e.getMessage(), model);
}
}
@GetMapping(value = {"/addon-discovery"})
public String addon_discovery(HttpSession session, Model model) {
try {
if (session == null || session.getAttribute("credentials") == null) {
return startAuthFlow(model);
}
return "addon-discovery";
} catch (Exception e) {
return onError(e.getMessage(), model);
}
}
Your authorization page should contain a link or button for the user to "sign
in". Clicking this should redirect the user to the authorize
route.
Request authorization
To request authorization, construct and redirect the user to an authentication URL. This URL includes several pieces of information, such as the scopes requested, the destination route for after authorization, and the web app's client ID. You can see these in this sample authorization URL.
Python
Add the following import to your routes.py
file.
import google_auth_oauthlib.flow
Create a new route /authorize
. Create an instance of
google_auth_oauthlib.flow.Flow
; we strongly recommend using the included
from_client_secrets_file
method to do so.
@app.route("/authorize")
def authorize():
# Create flow instance to manage the OAuth 2.0 Authorization Grant Flow
# steps.
flow = google_auth_oauthlib.flow.Flow.from_client_secrets_file(
CLIENT_SECRETS_FILE, scopes=SCOPES)
Set the flow
's redirect_uri
; this is the route to which you intend users
to return after authorizing your app. This is /callback
in the following
example.
# The URI created here must exactly match one of the authorized redirect
# URIs for the OAuth 2.0 client, which you configured in the API Console. If
# this value doesn't match an authorized URI, you will get a
# "redirect_uri_mismatch" error.
flow.redirect_uri = flask.url_for("callback", _external=True)
Use the flow object to construct the authorization_url
and state
. Store
the state
in the session; it's used to verify the authenticity of the
server response later. Finally, redirect the user to the
authorization_url
.
authorization_url, state = flow.authorization_url(
# Enable offline access so that you can refresh an access token without
# re-prompting the user for permission. Recommended for web server apps.
access_type="offline",
# Enable incremental authorization. Recommended as a best practice.
include_granted_scopes="true")
# Store the state so the callback can verify the auth server response.
flask.session["state"] = state
# Redirect the user to the OAuth authorization URL.
return flask.redirect(authorization_url)
Java
Add the following methods to the AuthService.java
file to instantiate the
flow object and then use it to retrieve the authorization URL:
getClientSecrets()
method reads the client secret file and constructs aGoogleClientSecrets
object.getFlow()
method creates an instance ofGoogleAuthorizationCodeFlow
.authorize()
method uses theGoogleAuthorizationCodeFlow
object, thestate
parameter, and the redirect URI to retrieve the authorization URL. Thestate
parameter is used to verify the authenticity of the response from the authorization server. The method then returns a map with the authorization URL and thestate
parameter.
/** Reads the client secret file downloaded from Google Cloud.
* @return GoogleClientSecrets read in from client secret file. */
public GoogleClientSecrets getClientSecrets() throws Exception {
try {
InputStream in = SignInApplication.class.getClassLoader()
.getResourceAsStream(CLIENT_SECRET_FILE);
if (in == null) {
throw new FileNotFoundException("Client secret file not found: "
+ CLIENT_SECRET_FILE);
}
GoogleClientSecrets clientSecrets = GoogleClientSecrets
.load(JSON_FACTORY, new InputStreamReader(in));
return clientSecrets;
} catch (Exception e) {
throw e;
}
}
/** Builds and returns authorization code flow.
* @return GoogleAuthorizationCodeFlow object used to retrieve an access
* token and refresh token for the application.
* @throws Exception if reading client secrets or building code flow object
* is unsuccessful.
*/
public GoogleAuthorizationCodeFlow getFlow() throws Exception {
try {
GoogleAuthorizationCodeFlow authorizationCodeFlow =
new GoogleAuthorizationCodeFlow.Builder(
HTTP_TRANSPORT,
JSON_FACTORY,
getClientSecrets(),
getScopes())
.setAccessType("offline")
.build();
return authorizationCodeFlow;
} catch (Exception e) {
throw e;
}
}
/** Builds and returns a map with the authorization URL, which allows the
* user to give the app permission to their account, and the state parameter,
* which is used to prevent cross site request forgery.
* @return map with authorization URL and state parameter.
* @throws Exception if building the authorization URL is unsuccessful.
*/
public HashMap authorize() throws Exception {
HashMap<String, String> authDataMap = new HashMap<>();
try {
String state = new BigInteger(130, new SecureRandom()).toString(32);
authDataMap.put("state", state);
GoogleAuthorizationCodeFlow flow = getFlow();
String authUrl = flow
.newAuthorizationUrl()
.setState(state)
.setRedirectUri(REDIRECT_URI)
.build();
String url = authUrl;
authDataMap.put("url", url);
return authDataMap;
} catch (Exception e) {
throw e;
}
}
Use constructor injection to create an instance of the service class in the controller class.
/** Declare AuthService to be used in the Controller class constructor. */
private final AuthService authService;
/** AuthController constructor. Uses constructor injection to instantiate
* the AuthService and UserRepository classes.
* @param authService the service class that handles the implementation logic
* of requests.
*/
public AuthController(AuthService authService) {
this.authService = authService;
}
Add the /authorize
endpoint to the controller class. This endpoint calls
the AuthService authorize()
method to retrieve the state
parameter
and the authorization URL. Then, the endpoint stores the state
parameter in the session and redirects users to the authorization URL.
/** Redirects the sign-in pop-up to the authorization URL.
* @param response the current response to pass information to.
* @param session the current session.
* @throws Exception if redirection to the authorization URL is unsuccessful.
*/
@GetMapping(value = {"/authorize"})
public void authorize(HttpServletResponse response, HttpSession session)
throws Exception {
try {
HashMap authDataMap = authService.authorize();
String authUrl = authDataMap.get("url").toString();
String state = authDataMap.get("state").toString();
session.setAttribute("state", state);
response.sendRedirect(authUrl);
} catch (Exception e) {
throw e;
}
}
Handle the server response
After authorizing, the user returns to the redirect_uri
route from the
previous step. In the preceding example, this route is /callback
.
You receive a code
in the response when the user returns from the
authorization page. Then exchange the code for access and refresh tokens:
Python
Add the following imports to your Flask server file.
import google.oauth2.credentials
import googleapiclient.discovery
Add the route to your server. Construct another instance of
google_auth_oauthlib.flow.Flow
, but this time reuse the state saved in the
previous step.
@app.route("/callback")
def callback():
state = flask.session["state"]
flow = google_auth_oauthlib.flow.Flow.from_client_secrets_file(
CLIENT_SECRETS_FILE, scopes=SCOPES, state=state)
flow.redirect_uri = flask.url_for("callback", _external=True)
Next, request access and refresh tokens. Fortunately, the flow
object also
contains the fetch_token
method to accomplish this. The method expects
either the code
or authorization_response
arguments. Use the
authorization_response
, as it's the full URL from the request.
authorization_response = flask.request.url
flow.fetch_token(authorization_response=authorization_response)
You now have complete credentials! Store them in the session so that they can be retrieved in other methods or routes, then redirect to an add-on landing page.
credentials = flow.credentials
flask.session["credentials"] = {
"token": credentials.token,
"refresh_token": credentials.refresh_token,
"token_uri": credentials.token_uri,
"client_id": credentials.client_id,
"client_secret": credentials.client_secret,
"scopes": credentials.scopes
}
# Close the pop-up by rendering an HTML page with a script that redirects
# the owner and closes itself. This can be done with a bit of JavaScript:
# <script>
# window.opener.location.href = "{{ url_for('classroom_addon') }}";
# window.close();
# </script>
return flask.render_template("close-me.html")
Java
Add a method to your service class that returns the Credentials
object by
passing in the authorization code retrieved from the redirect performed by
the authorization URL. This Credentials
object is used later to retrieve
the access token and refresh token.
/** Returns the required credentials to access Google APIs.
* @param authorizationCode the authorization code provided by the
* authorization URL that's used to obtain credentials.
* @return the credentials that were retrieved from the authorization flow.
* @throws Exception if retrieving credentials is unsuccessful.
*/
public Credential getAndSaveCredentials(String authorizationCode) throws Exception {
try {
GoogleAuthorizationCodeFlow flow = getFlow();
GoogleClientSecrets googleClientSecrets = getClientSecrets();
TokenResponse tokenResponse = flow.newTokenRequest(authorizationCode)
.setClientAuthentication(new ClientParametersAuthentication(
googleClientSecrets.getWeb().getClientId(),
googleClientSecrets.getWeb().getClientSecret()))
.setRedirectUri(REDIRECT_URI)
.execute();
Credential credential = flow.createAndStoreCredential(tokenResponse, null);
return credential;
} catch (Exception e) {
throw e;
}
}
Add an endpoint for your redirect URI to the controller. Retrieve the
authorization code and state
parameter from the request. Compare this
state
parameter to the state
attribute stored in the session. If they
match, then continue with the authorization flow. If they don't match,
return an error.
Then, call the AuthService
getAndSaveCredentials
method and pass in the
authorization code as a parameter. After retrieving the Credentials
object, store it in the session. Then, close the dialog and redirect the
user to the add-on landing page.
/** Handles the redirect URL to grant the application access to the user's
* account.
* @param request the current request used to obtain the authorization code
* and state parameter from.
* @param session the current session.
* @param response the current response to pass information to.
* @param model the Model interface to pass error information that's
* displayed on the error page.
* @return the close-pop-up template if authorization is successful, or the
* onError method to handle and display the error message.
*/
@GetMapping(value = {"/callback"})
public String callback(HttpServletRequest request, HttpSession session,
HttpServletResponse response, Model model) {
try {
String authCode = request.getParameter("code");
String requestState = request.getParameter("state");
String sessionState = session.getAttribute("state").toString();
if (!requestState.equals(sessionState)) {
response.setStatus(401);
return onError("Invalid state parameter.", model);
}
Credential credentials = authService.getAndSaveCredentials(authCode);
session.setAttribute("credentials", credentials);
return "close-pop-up";
} catch (Exception e) {
return onError(e.getMessage(), model);
}
}
Test an API call
With the flow complete, you can now issue calls to Google APIs!
As an example, request the user's profile information. You can request the user's information from the OAuth 2.0 API.
Python
Read the documentation for the OAuth 2.0 discovery API Use it to get a populated UserInfo object.
# Retrieve the credentials from the session data and construct a
# Credentials instance.
credentials = google.oauth2.credentials.Credentials(
**flask.session["credentials"])
# Construct the OAuth 2.0 v2 discovery API library.
user_info_service = googleapiclient.discovery.build(
serviceName="oauth2", version="v2", credentials=credentials)
# Request and store the username in the session.
# This allows it to be used in other methods or in an HTML template.
flask.session["username"] = (
user_info_service.userinfo().get().execute().get("name"))
Java
Create a method in the service class that builds a UserInfo
object using
the Credentials
as a parameter.
/** Obtains the Userinfo object by passing in the required credentials.
* @param credentials retrieved from the authorization flow.
* @return the Userinfo object for the currently signed-in user.
* @throws IOException if creating UserInfo service or obtaining the
* Userinfo object is unsuccessful.
*/
public Userinfo getUserInfo(Credential credentials) throws IOException {
try {
Oauth2 userInfoService = new Oauth2.Builder(
new NetHttpTransport(),
new GsonFactory(),
credentials).build();
Userinfo userinfo = userInfoService.userinfo().get().execute();
return userinfo;
} catch (Exception e) {
throw e;
}
}
Add the /test
endpoint to the controller that displays the user's email.
/** Returns the test request page with the user's email.
* @param session the current session.
* @param model the Model interface to pass error information that's
* displayed on the error page.
* @return the test page that displays the current user's email or the
* onError method to handle and display the error message.
*/
@GetMapping(value = {"/test"})
public String test(HttpSession session, Model model) {
try {
Credential credentials = (Credential) session.getAttribute("credentials");
Userinfo userInfo = authService.getUserInfo(credentials);
String userInfoEmail = userInfo.getEmail();
if (userInfoEmail != null) {
model.addAttribute("userEmail", userInfoEmail);
} else {
return onError("Could not get user email.", model);
}
return "test";
} catch (Exception e) {
return onError(e.getMessage(), model);
}
}
Clear credentials
You can "clear" a user's credentials by removing them from the current session. This lets you test the routing on the add-on landing page.
We recommend showing an indication that the user has signed out before redirecting them to the add-on landing page. Your app should go through the authorization flow to obtain new credentials, but users are not prompted to re-authorize your app.
Python
@app.route("/clear")
def clear_credentials():
if "credentials" in flask.session:
del flask.session["credentials"]
del flask.session["username"]
return flask.render_template("signed-out.html")
Alternatively, use flask.session.clear()
, but this may have unintended
effects if you have other values stored in the session.
Java
In the controller, add a /clear
endpoint.
/** Clears the credentials in the session and returns the sign-out
* confirmation page.
* @param session the current session.
* @return the sign-out confirmation page.
*/
@GetMapping(value = {"/clear"})
public String clear(HttpSession session) {
try {
if (session != null && session.getAttribute("credentials") != null) {
session.removeAttribute("credentials");
}
return "sign-out";
} catch (Exception e) {
return onError(e.getMessage(), model);
}
}
Revoke the app's permission
A user can revoke your app's permission by sending a POST
request to
https://oauth2.googleapis.com/revoke
. The request should contain the user's
access token.
Python
import requests
@app.route("/revoke")
def revoke():
if "credentials" not in flask.session:
return flask.render_template("addon-discovery.html",
message="You need to authorize before " +
"attempting to revoke credentials.")
credentials = google.oauth2.credentials.Credentials(
**flask.session["credentials"])
revoke = requests.post(
"https://oauth2.googleapis.com/revoke",
params={"token": credentials.token},
headers={"content-type": "application/x-www-form-urlencoded"})
if "credentials" in flask.session:
del flask.session["credentials"]
del flask.session["username"]
status_code = getattr(revoke, "status_code")
if status_code == 200:
return flask.render_template("authorization.html")
else:
return flask.render_template(
"index.html", message="An error occurred during revocation!")
Java
Add a method to the service class that makes a call to the revoke endpoint.
/** Revokes the app's permissions to the user's account.
* @param credentials retrieved from the authorization flow.
* @return response entity returned from the HTTP call to obtain response
* information.
* @throws RestClientException if the POST request to the revoke endpoint is
* unsuccessful.
*/
public ResponseEntity<String> revokeCredentials(Credential credentials) throws RestClientException {
try {
String accessToken = credentials.getAccessToken();
String url = "https://oauth2.googleapis.com/revoke?token=" + accessToken;
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE);
HttpEntity<Object> httpEntity = new HttpEntity<Object>(httpHeaders);
ResponseEntity<String> responseEntity = new RestTemplate().exchange(
url,
HttpMethod.POST,
httpEntity,
String.class);
return responseEntity;
} catch (RestClientException e) {
throw e;
}
}
Add an endpoint, /revoke
, to the controller that clears the session and
redirects the user to the authorization page if the revocation was
successful.
/** Revokes the app's permissions and returns the authorization page.
* @param session the current session.
* @return the authorization page.
* @throws Exception if revoking access is unsuccessful.
*/
@GetMapping(value = {"/revoke"})
public String revoke(HttpSession session) throws Exception {
try {
if (session != null && session.getAttribute("credentials") != null) {
Credential credentials = (Credential) session.getAttribute("credentials");
ResponseEntity responseEntity = authService.revokeCredentials(credentials);
Integer httpStatusCode = responseEntity.getStatusCodeValue();
if (httpStatusCode != 200) {
return onError("There was an issue revoking access: " +
responseEntity.getStatusCode(), model);
}
session.removeAttribute("credentials");
}
return startAuthFlow(model);
} catch (Exception e) {
return onError(e.getMessage(), model);
}
}
Test the add-on
Sign in to Google Classroom as one of your Teacher test users. Navigate to the Classwork tab and create a new Assignment. Click the Add-ons button below the text area, then select your add-on. The iframe opens and the add-on loads the Attachment Setup URI that you specified in the GWM SDK's App Configuration page.
Congratulations! You're ready to proceed to the next step: handling repeat visits to your add-on.