aboutsummaryrefslogtreecommitdiffstats
path: root/src/net/java/sip/communicator/impl/googlecontacts/OAuth2TokenStore.java
blob: b5e77eab0d870e2fa69994e01edb138ac4376fe3 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
/*
 * Copyright @ 2015 Atlassian Pty Ltd
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package net.java.sip.communicator.impl.googlecontacts;

import java.awt.*;
import java.awt.event.*;
import java.io.*;
import java.net.*;
import java.util.*;
import java.util.concurrent.atomic.*;

import javax.swing.*;

import net.java.sip.communicator.plugin.desktoputil.*;
import net.java.sip.communicator.service.credentialsstorage.*;
import net.java.sip.communicator.util.*;

import org.apache.http.HttpResponse;
import org.apache.http.client.*;
import org.apache.http.client.entity.*;
import org.apache.http.client.methods.*;
import org.apache.http.impl.client.*;
import org.apache.http.message.*;
import org.jitsi.service.resources.*;

import com.google.api.client.auth.oauth2.*;
import com.google.api.client.http.*;
import com.google.api.client.http.HttpRequest;
import com.google.api.client.http.javanet.*;
import com.google.api.client.json.*;
import com.google.api.client.json.jackson2.*;

/**
 * OAuth 2 token store.
 *
 * @author Danny van Heumen
 */
public class OAuth2TokenStore
{

    /**
     * Logger.
     */
    private static final Logger LOGGER = Logger
        .getLogger(OAuth2TokenStore.class);

    /**
     * Symbol for refresh token in token server response.
     */
    private static final String REFRESH_TOKEN_SYMBOL = "refresh_token";

    /**
     * Symbol for access token in token server response.
     */
    private static final String ACCESS_TOKEN_SYMBOL = "access_token";

    /**
     * Symbol for expiration time in token server response.
     */
    private static final String EXPIRES_IN_SYMBOL = "expires_in";

    /**
     * Interesting token server response fields.
     */
    private static final Set<String> TOKEN_RESPONSE_FIELDS;

    static
    {
        final HashSet<String> set = new HashSet<String>();
        set.add(REFRESH_TOKEN_SYMBOL);
        set.add(ACCESS_TOKEN_SYMBOL);
        set.add(EXPIRES_IN_SYMBOL);
        TOKEN_RESPONSE_FIELDS = Collections.unmodifiableSet(set);
    }

    /**
     * Google OAuth 2 token server.
     */
    private static final GenericUrl GOOGLE_OAUTH2_TOKEN_SERVER =
        new GenericUrl("https://accounts.google.com/o/oauth2/token");

    /**
     * Client ID for OAuth 2 based authentication.
     */
    private static final String GOOGLE_API_CLIENT_ID
        = GoogleAPIClientToken.GOOGLE_API_CLIENT_ID;

    /**
     * Client secret for OAuth 2 based authentication.
     */
    private static final String GOOGLE_API_CLIENT_SECRET
        = GoogleAPIClientToken.GOOGLE_API_CLIENT_SECRET;

    /**
     * Required OAuth 2 authentication scopes.
     */
    private static final String GOOGLE_API_OAUTH2_SCOPES =
        "profile%20email%20https://www.google.com/m8/feeds";

    /**
     * OAuth 2 redirect URL.
     */
    private static final String GOOGLE_API_OAUTH2_REDIRECT_URI =
        "urn:ietf:wg:oauth:2.0:oob";

    /**
     * Grant type for communication with token server.
     */
    private static final String GOOGLE_API_GRANT_TYPE = "authorization_code";

    /**
     * Approval URL.
     */
    private static final String APPROVAL_URL = String.format(
        "https://accounts.google.com/o/oauth2/auth?scope=%s&redirect_uri=%s"
            + "&response_type=code&client_id=%s", GOOGLE_API_OAUTH2_SCOPES,
        GOOGLE_API_OAUTH2_REDIRECT_URI, GOOGLE_API_CLIENT_ID);

    /**
     * The credential store.
     *
     * Note: The AtomicReference container is used as a shared container that is
     * also passed on to some of the registered listeners for updating the
     * credential data.
     */
    private final AtomicReference<Credential> store =
        new AtomicReference<Credential>(null);

    /**
     * Get the credential from the store. In case a credential does not (yet)
     * exist, acquire one preferrably from the password store. Optionally,
     * involve the user if a credential is not yet stored.
     * 
     * @param identity The identity of the API token.
     * @return Returns the credential.
     * @throws FailedAcquireCredentialException 
     * @throws MalformedURLException In case requesting authn token failed.
     */
    public synchronized Credential get(final String identity)
        throws FailedAcquireCredentialException
    {
        if (GOOGLE_API_CLIENT_ID == null || GOOGLE_API_CLIENT_SECRET == null)
        {
            throw new IllegalStateException("Missing client ID or client "
                + "secret. It is not possible to use Google Contacts API "
                + "without it.");
        }
        if (this.store.get() == null)
        {
            try
            {
                acquireCredential(this.store, identity);
            }
            catch (Exception e)
            {
                throw new FailedAcquireCredentialException(e);
            }
        }
        // should make sure that only succeeded requests reach up to here
        return this.store.get();
    }

    /**
     * Acquire a new credential instance.
     *
     * @param store credential store to update upon refreshing and other
     *            operations
     * @param identity the identity to which the refresh token belongs
     * @return Acquires and returns the credential instance.
     * @throws URISyntaxException In case of bad redirect URI.
     * @throws IOException 
     * @throws ClientProtocolException 
     */
    private static void acquireCredential(
        final AtomicReference<Credential> store, final String identity)
        throws URISyntaxException, ClientProtocolException, IOException
    {
        final TokenData token;
        String refreshToken = restoreRefreshToken(identity);
        if (refreshToken == null)
        {
            LOGGER.info("No credentials available yet. Requesting user to "
                + "approve access to Contacts API for identity " + identity
                + " using URL: " + APPROVAL_URL);
            final OAuthApprovalDialog dialog =
                new OAuthApprovalDialog(identity);
            // Synchronize on OAuth approval dialog CLASS, to ensure that only
            // one dialog shows at a time.
            synchronized (OAuthApprovalDialog.class)
            {
                dialog.setVisible(true);
            }
            switch (dialog.getResponse())
            {
            case CONFIRMED:
                // dialog is confirmed, so process entered approval code
                final String approvalCode = dialog.getApprovalCode();
                LOGGER.debug("Approval code from user: " + approvalCode);
                token = requestAuthenticationToken(approvalCode);
                saveRefreshToken(token, identity);
                break;
            case CANCELLED:
            default:
                // user one time cancellation
                // let token remain null, as we do not have new information
                // yet
                token = null;
                break;
            }
        }
        else
        {
            token = new TokenData(null, refreshToken, 0);
        }
        store.set(createCredential(store, token));
    }

    /**
     * Restore refresh token from encrypted credentials store.
     *
     * @param identity The identity corresponding to the refresh token.
     * @return Returns the refresh token.
     */
    private static String restoreRefreshToken(final String identity)
    {
        final CredentialsStorageService credentials =
            GoogleContactsActivator.getCredentialsService();
        return credentials
            .loadPassword(GoogleContactsServiceImpl.CONFIGURATION_PATH + "."
                + identity);
    }

    /**
     * Save refresh token for provided identity.
     *
     * @param token The refresh token.
     * @param identity The identity.
     * @throws IOException An IOException in case of errors.
     */
    private static void saveRefreshToken(final TokenData token,
        final String identity) throws IOException
    {
        final CredentialsStorageService credentials =
            GoogleContactsActivator.getCredentialsService();
        credentials.storePassword(GoogleContactsServiceImpl.CONFIGURATION_PATH
            + "." + identity, token.refreshToken);
    }

    /**
     * Refresh OAuth2 authentication token.
     *
     * @throws IOException
     * @throws FailedTokenRefreshException In case of failed token refresh
     *             operation.
     */
    public synchronized void refresh() throws IOException, FailedTokenRefreshException
    {
        final Credential credential = this.store.get();
        if (credential == null)
        {
            throw new IllegalStateException("A credential instance should "
                + "exist, but it does not. This is likely due to a bug.");
        }
        if (!credential.refreshToken())
        {
            LOGGER.warn("Refresh of OAuth2 authentication token failed.");
            throw new FailedTokenRefreshException();
        }
    }

    /**
     * Create credential instance suitable for use in Google Contacts API.
     * 
     * @param store reference to the credential store for updating credential
     *            data upon refreshing and other cases
     * @param approvalCode the approval code received from Google by the user
     *            accepting the authorization request
     * @return Returns a Credential instance.
     * @throws URISyntaxException In case of bad OAuth 2 redirect URI.
     */
    private static Credential createCredential(
        final AtomicReference<Credential> store, final TokenData data)
        throws URISyntaxException
    {
        final Credential.Builder builder =
            new Credential.Builder(
                BearerToken.authorizationHeaderAccessMethod());
        builder.setTokenServerUrl(GOOGLE_OAUTH2_TOKEN_SERVER);
        builder.setTransport(new NetHttpTransport());
        builder.setJsonFactory(new JacksonFactory());
        builder.setClientAuthentication(new HttpExecuteInterceptor()
        {

            @Override
            public void intercept(HttpRequest request) throws IOException
            {
                final Object data =
                    ((UrlEncodedContent) request.getContent()).getData();
                if (data instanceof RefreshTokenRequest)
                {
                    // Inject client authentication credentials in requests.
                    final RefreshTokenRequest content =
                        (RefreshTokenRequest) data;
                    content.put("client_id", GOOGLE_API_CLIENT_ID);
                    content.put("client_secret", GOOGLE_API_CLIENT_SECRET);
                    LOGGER.info("Inserting client authentication data into "
                        + "refresh token request.");
                    if (LOGGER.isDebugEnabled())
                    {
                        LOGGER.debug("Request: " + content.toString());
                    }
                }
                else
                {
                    LOGGER.debug("Unexpected type of request found.");
                }
            }
        });
        builder.addRefreshListener(new CredentialRefreshListener()
        {

            @Override
            public void onTokenResponse(Credential credential,
                TokenResponse tokenResponse) throws IOException
            {
                LOGGER.debug("Successful token refresh response: "
                    + tokenResponse.toPrettyString());
                store.set(credential);
            }

            @Override
            public void onTokenErrorResponse(Credential credential,
                TokenErrorResponse tokenErrorResponse) throws IOException
            {
                if (LOGGER.isDebugEnabled())
                {
                    LOGGER.debug("Failed token refresh response: "
                        + tokenErrorResponse.toPrettyString());
                }
                LOGGER.error("Failed to refresh OAuth2 token: "
                    + tokenErrorResponse.getError() + ": "
                    + tokenErrorResponse.getErrorDescription());
            }
        });
        final Credential credential = builder.build();
        credential.setAccessToken(data.accessToken);
        credential.setRefreshToken(data.refreshToken);
        credential.setExpiresInSeconds(data.expiration);
        return credential;
    }

    /**
     * Request an authentication token using the approval code received from the
     * user.
     * 
     * @param approvalCode the approval code
     * @return Returns the acquired token data from OAuth 2 token server.
     * @throws IOException 
     * @throws ClientProtocolException 
     */
    private static TokenData requestAuthenticationToken(
        final String approvalCode) throws ClientProtocolException, IOException
    {
        final HttpClient client = new DefaultHttpClient();
        final HttpPost post = new HttpPost(GOOGLE_OAUTH2_TOKEN_SERVER.toURI());
        final UrlEncodedFormEntity entity =
            new UrlEncodedFormEntity(Arrays.asList(new BasicNameValuePair(
                "code", approvalCode), new BasicNameValuePair("client_id",
                GOOGLE_API_CLIENT_ID), new BasicNameValuePair("client_secret",
                GOOGLE_API_CLIENT_SECRET), new BasicNameValuePair(
                "redirect_uri", GOOGLE_API_OAUTH2_REDIRECT_URI),
                new BasicNameValuePair("grant_type", GOOGLE_API_GRANT_TYPE)));
        post.setEntity(entity);
        final HttpResponse httpResponse = client.execute(post);
        final JsonParser parser =
            JacksonFactory.getDefaultInstance().createJsonParser(
                httpResponse.getEntity().getContent());
        try
        {
            // Token response components initialized with defaults in case
            // fields are missing in the token server response.
            String accessToken = "";
            String refreshToken = "";
            long expiresIn = 3600;
            // Parse token server response.
            String found;
            while (parser.nextToken() != JsonToken.END_OBJECT)
            {
                found = parser.skipToKey(TOKEN_RESPONSE_FIELDS);
                if (REFRESH_TOKEN_SYMBOL.equals(found))
                {
                    refreshToken = parser.getText();
                }
                else if (ACCESS_TOKEN_SYMBOL.equals(found))
                {
                    accessToken = parser.getText();
                }
                else if (EXPIRES_IN_SYMBOL.equals(found))
                {
                    expiresIn = parser.getLongValue();
                }
            }
            return new TokenData(accessToken, refreshToken, expiresIn);
        }
        finally
        {
            parser.close();
        }
    }

    /**
     * OAuth 2 approval dialog for instructing user for instructing the user to
     * open a web browser and approve Jitsi Google Contacts plugin access and
     * for receiving the resulting approval code.
     *
     * @author Danny van Heumen
     */
    private static class OAuthApprovalDialog
        extends SIPCommDialog
    {
        private static final long serialVersionUID = 6792589736608633346L;

        private final SIPCommLinkButton link;

        private final SIPCommTextField code = new SIPCommTextField("");

        // Initialize behavior of code input field.
        {
            code.setFont(code.getFont().deriveFont(Font.BOLD, 12));
            code.setForeground(Color.BLACK);
        }

        private UserResponseType response = UserResponseType.CANCELLED;

        /**
         * Construct and initialize the OAuth 2 approval dialog.
         *
         * @param identity The identity for which approval is requested.
         */
        public OAuthApprovalDialog(final String identity)
        {
            super(false);

            final ResourceManagementService resources =
                GoogleContactsActivator.getResourceManagementService();
            final String instructionsText =
                resources.getI18NString("impl.googlecontacts.INSTRUCTIONS");

            // configure dialog
            this.setTitle(resources
                .getI18NString("impl.googlecontacts.OAUTH_DIALOG_TITLE"));
            this.setMinimumSize(new Dimension(20, 20));
            this.setPreferredSize(new Dimension(650, 220));
            this.setBounds(10, 10, this.getWidth() - 20, this.getHeight() - 20);
            this.setModal(true);

            // main panel layout
            JPanel mainPanel = new TransparentPanel(new BorderLayout());
            mainPanel.setBorder(
                BorderFactory.createEmptyBorder(10, 10, 5, 10));

            final JPanel instructionPanel
                = new TransparentPanel(new BorderLayout());
            mainPanel.add(instructionPanel, BorderLayout.NORTH);

            final JPanel buttonPanel = new TransparentPanel(new BorderLayout());
            mainPanel.add(buttonPanel, BorderLayout.SOUTH);

            // populate instruction dialog
            final JLabel instructionLabel = new JLabel(instructionsText);
            instructionPanel.add(instructionLabel,
                BorderLayout.CENTER);
            this.link =
                new SIPCommLinkButton(resources.getI18NString(
                    "impl.googlecontacts.HYPERLINK_TEXT", new String[]
                    { identity }));
            this.link.addActionListener(new ActionListener()
            {

                @Override
                public void actionPerformed(ActionEvent e)
                {
                    LOGGER.info("Requesting user for approval via web page: "
                        + APPROVAL_URL);
                    GoogleContactsActivator.getBrowserLauncherService()
                        .openURL(APPROVAL_URL);
                }
            });
            instructionPanel.add(this.link, BorderLayout.SOUTH);

            // input controls on main panel
            final JLabel codeLabel =
                new JLabel(resources.getI18NString("impl.googlecontacts.CODE"));
            codeLabel.setOpaque(false);
            mainPanel.add(codeLabel, BorderLayout.WEST);
            mainPanel.add(this.code, BorderLayout.CENTER);

            // buttons panel
            final JButton doneButton =
                new JButton(resources.getI18NString("service.gui.SAVE"));
            doneButton.setMnemonic('s');
            doneButton.addActionListener(new ActionListener()
            {

                @Override
                public void actionPerformed(ActionEvent e)
                {
                    OAuthApprovalDialog.this.response =
                        UserResponseType.CONFIRMED;
                    OAuthApprovalDialog.this.dispose();
                }
            });
            buttonPanel.add(doneButton, BorderLayout.EAST);

            this.add(mainPanel);

            this.pack();
        }

        /**
         * Get approval code entered by the user in the dialog input field.
         *
         * @return Returns the approval code.
         */
        public String getApprovalCode()
        {
            return this.code.getText();
        }

        /**
         * Is approval dialog confirmed with "Done" button.
         *
         * @return Returns whether or not the dialog is confirmed.
         */
        public UserResponseType getResponse()
        {
            return this.response;
        }
    }

    /**
     * Container for token data for internal use.
     *
     * @author Danny van Heumen
     */
    private static class TokenData
    {
        /**
         * OAuth 2 access token.
         */
        private final String accessToken;

        /**
         * OAuth 2 refresh token.
         */
        private final String refreshToken;

        /**
         * Available time before expiration of the current access token.
         */
        private final long expiration;

        /**
         * Constructor for TokenData container.
         *
         * @param accessToken the access token
         * @param refreshToken the refresh token (cannot be null)
         * @param expirationTime the expiration time (must be >= 0)
         */
        private TokenData(final String accessToken, final String refreshToken,
            final long expirationTime)
        {
            this.accessToken = accessToken;
            if (refreshToken == null)
            {
                throw new NullPointerException("refresh token cannot be null");
            }
            this.refreshToken = refreshToken;
            if (expirationTime < 0)
            {
                throw new IllegalArgumentException(
                    "Expiration time cannot be null");
            }
            this.expiration = expirationTime;
        }
    }

    /**
     * Exception for error case where we failed to acquire initial credential
     * for OAuth 2 authentication and authorization.
     * 
     * @author Danny van Heumen
     */
    public static class FailedAcquireCredentialException
        extends Exception
    {
        private static final long serialVersionUID = 5810534617383420431L;

        private FailedAcquireCredentialException(final Throwable cause)
        {
            super(cause);
        }
    }

    /**
     * Exception for error case where we failed to refresh the OAuth 2 authn
     * token.
     * 
     * @author Danny van Heumen
     */
    public static class FailedTokenRefreshException
        extends Exception
    {
        private static final long serialVersionUID = 3166027054735734199L;

        private FailedTokenRefreshException()
        {
            super("Failed to refresh OAuth2 token.");
        }
    }
}