1/*! angular-google-gis - Google Identity Services (GIS) replacement for angular-google-plus 2 * 3 * Drop-in for the deprecated angular-google-plus v0.1.3 (gapi.auth) library. 4 * Preserves the same module name ('googleplus') and the same GooglePlus provider 5 * API ( .init() / .login() / .getUser() ) so existing callers in app.js and 6 * login.js work unchanged. 7 * 8 * Two sign-in flows are supported, both validated by api/song_login.php: 9 * 10 * 1. Rendered-button id-token flow (renderButton with a credential handler) - 11 * Google's own button presents the compact FedCM account chooser and returns 12 * an id_token, sent with authmode=googlesignin so the backend verifies it via 13 * verifyIdToken. This is what the Songtree buttons and n-Track use. 14 * 15 * 2. OAuth token-client flow (login()/getUser(), google.accounts.oauth2) - 16 * returns an access token, matching the backend's access-token validation 17 * path (no authmode sent). Opens a full-size OAuth window rather than the 18 * compact chooser. Retained for API compatibility and for callers that need 19 * an access token. 20 * 21 * Requires the GIS loader on the page: 22 * <script src="https://accounts.google.com/gsi/client" async defer></script> 23 */ 24angular.module('googleplus', []) 25 .provider('GooglePlus', [function () { 26 27 var config = { 28 clientId: null, 29 apiKey: null, // accepted for compatibility; unused by GIS 30 scopes: 'openid email profile' 31 }; 32 33 this.setClientId = function (v) { config.clientId = v; return this; }; 34 this.getClientId = function () { return config.clientId; }; 35 this.setApiKey = function (v) { config.apiKey = v; return this; }; 36 this.getApiKey = function () { return config.apiKey; }; 37 this.setScopes = function (v) { config.scopes = v; return this; }; 38 this.getScopes = function () { return config.scopes; }; 39 this.init = function (obj) { angular.extend(config, obj); }; 40 41 // Kept for API compatibility with the old provider (no-ops under GIS). 42 this.enableServerSide = function () {}; 43 this.disableServerSide = function () {}; 44 45 // FedCM fallback. GIS decides to use FedCM from the user agent, so Chromium 46 // browsers that report a Chrome UA but don't implement FedCM (Arc, for one) 47 // have navigator.credentials.get() rejected with NotSupportedError. GIS only 48 // logs that and drops the sign-in, so we watch for the rejection ourselves, 49 // switch GIS to its non-FedCM popup flow (the one Firefox and Safari use), and 50 // remember the result so later page loads start on that flow. 51 // Only NotSupportedError counts: Chrome's own FedCM failures (chooser 52 // dismissed, third-party sign-in blocked) reject with NetworkError/ 53 // NotAllowedError, and dropping FedCM there would put Chrome on the popup flow 54 // that third-party cookie blocking breaks. 55 var FEDCM_OFF_KEY = 'gis_fedcm_unsupported'; 56 var FEDCM_OFF_TTL = 30 * 24 * 60 * 60 * 1000; // try FedCM again after 30 days 57 var fedcmSupported = !fedcmMarkedUnsupported(); 58 var onFedcmUnsupported = null; // installed by $get 59 60 function fedcmMarkedUnsupported() { 61 try { 62 var markedAt = parseInt(window.localStorage.getItem(FEDCM_OFF_KEY), 10); 63 return !isNaN(markedAt) && (Date.now() - markedAt) < FEDCM_OFF_TTL; 64 } catch (e) { 65 return false; 66 } 67 } 68 69 // Wrap navigator.credentials.get so a FedCM request GIS makes from this page 70 // can be observed. Installed when the provider is created, before any sign-in 71 // can start. The original promise is returned untouched, so GIS's own handling 72 // (and password/passkey requests, which carry no `identity`) are unaffected. 73 (function watchForFedcmUnsupported() { 74 var creds = window.navigator && navigator.credentials; 75 if (!creds || typeof creds.get !== 'function' || creds.get.gisFedcmWatched) { return; } 76 var originalGet = creds.get; 77 var watchedGet = function (options) { 78 var result = originalGet.apply(creds, arguments); 79 if (options && options.identity && result && typeof result.then === 'function') { 80 result.then(null, function (err) { 81 if (err && err.name === 'NotSupportedError' && onFedcmUnsupported) { onFedcmUnsupported(); } 82 }); 83 } 84 return result; 85 }; 86 watchedGet.gisFedcmWatched = true;
87 try { creds.get = watchedGet; } catch (e) {} 88 })(); 89 90 this.$get = ['$q', '$http', '$timeout', function ($q, $http, $timeout) { 91 92 var tokenClient = null; // lazily created GIS token client 93 var currentToken = null; // most recent access token 94 var pending = null; // deferred for the in-flight login() 95 96 var idInitialized = false; // google.accounts.id.initialize() done once 97 98 // Google exposes a single global credential callback and doesn't tell us 99 // which rendered button produced it, but a page can hold several buttons 100 // with different completions (login vs. register-with-captcha vs. 101 // onboarding). These let us route a credential to the right one. 102 var credentialHandlers = []; // [{ element, handler }] in render order 103 var lastClickedElement = null; // set by each button's capture listener 104 var oneTapHandler = null; // set by promptOneTap() 105 106 var renderedButtons = []; // [{ element, opts, onClick, onCredential }] drawn so far 107 var buttonClickedAt = 0; // set by each button's click_listener 108 var fedcmFallbackDone = false; 109 110 onFedcmUnsupported = function () { 111 if (fedcmFallbackDone) { return; } 112 fedcmFallbackDone = true; 113 fedcmSupported = false; 114 try { window.localStorage.setItem(FEDCM_OFF_KEY, String(Date.now())); } catch (e) {} 115 if (window.console) { console.log('GIS: FedCM is not supported by this browser; using the sign-in popup instead.'); } 116 117 if (!idInitialized) { return; } 118 119 // Re-initialize with FedCM off; GIS applies the most recent initialize() 120 // config (it logs a "called multiple times" warning, expected here). 121 idInitialized = false; 122 ensureIdInitialized(); 123 124 // Was this a button click? GIS reports clicks through click_listener; as a 125 // backup, a click into Google's button iframe leaves that iframe focused. 126 var active = document.activeElement; 127 var fromClick = (Date.now() - buttonClickedAt) < 5000; 128 var i, entry; 129 for (i = 0; i < renderedButtons.length && !fromClick; i++) { 130 if (active && active.tagName === 'IFRAME' && renderedButtons[i].element.contains(active)) { fromClick = true; } 131 } 132 133 // Buttons already on the page were rendered for FedCM, so draw them again. 134 // Visibility is read before clearing, since an emptied container can collapse. 135 for (i = 0; i < renderedButtons.length; i++) { 136 entry = renderedButtons[i]; 137 if (!document.documentElement.contains(entry.element)) { continue; } // removed by ng-if etc. 138 var visible = entry.element.offsetWidth > 0; 139 entry.element.innerHTML = ''; 140 if (visible) { 141 drawButton(entry); 142 // The click that hit the error is lost: ask for one more. Later page 143 // loads start on the popup flow, so this only happens once. 144 if (fromClick) { showRetryNote(entry.element); } 145 } else { 146 drawWhenVisible(entry); 147 } 148 } 149 150 // The failure came from the One Tap card: try it once more without FedCM. 151 if (!fromClick && oneTapHandler) { google.accounts.id.prompt(); } 152 }; 153 154 function showRetryNote(element) { 155 var next = element.nextElementSibling; 156 if (next && next.className === 'google-retry-note') { return; } 157 var note = document.createElement('div'); 158 note.className = 'google-retry-note'; 159 note.style.cssText = 'font-size:13px;margin:6px 0;text-align:center;'; 160 note.textContent = 'Please click Continue with Google again.'; 161 element.parentNode.insertBefore(note, element.nextSibling); 162 } 163 164 function isVisible(el) { 165 return !!(el && el.offsetParent !== null && el.offsetWidth > 0); 166 } 167 168 // Pick the handler for the button the user actually used. The click listener 169 // is the reliable signal; the visibility check is a fallback for the case 170 // where Google renders the button inside an iframe and the click never 171 // reaches our container.
172 function resolveCredentialHandler() { 173 var i; 174 if (lastClickedElement) { 175 for (i = 0; i < credentialHandlers.length; i++) { 176 if (credentialHandlers[i].element === lastClickedElement) { 177 return credentialHandlers[i].handler; 178 } 179 } 180 } 181 for (i = credentialHandlers.length - 1; i >= 0; i--) { 182 if (isVisible(credentialHandlers[i].element)) { 183 return credentialHandlers[i].handler; 184 } 185 } 186 return credentialHandlers.length ? credentialHandlers[credentialHandlers.length - 1].handler : null; 187 } 188 189 // google.accounts.id.initialize() may only be called once per page, and both 190 // the rendered buttons and One Tap share it (and its single credential 191 // callback), so it lives here rather than inside renderButton(). 192 function ensureIdInitialized() { 193 if (idInitialized) { return; } 194 google.accounts.id.initialize({ 195 client_id: config.clientId, 196 auto_select: false, // always show the card; never sign in silently 197 ux_mode: 'popup', 198 // FedCM is what lets the credential come back without relying on 199 // third-party cookies, and is what renders the compact chooser. 200 // use_fedcm_for_prompt is required for One Tap: Google has deprecated 201 // the non-FedCM prompt, and without it the card stops appearing. 202 // Both are turned off only for browsers that reject FedCM outright 203 // (see onFedcmUnsupported). 204 use_fedcm_for_button: fedcmSupported, 205 use_fedcm_for_prompt: fedcmSupported, 206 itp_support: true, 207 callback: function (resp) { 208 if (!resp || !resp.credential) { 209 if (window.console) { console.warn('GIS: sign-in returned no credential', resp); } 210 return; 211 } 212 // select_by tells us which surface produced the credential: anything 213 // starting with 'btn' came from a rendered button, everything else 214 // ('user', 'user_1tap', 'auto', ...) came from the One Tap card. 215 var fromButton = resp.select_by && resp.select_by.indexOf('btn') === 0; 216 var handler = fromButton ? resolveCredentialHandler() : (oneTapHandler || resolveCredentialHandler()); 217 if (!handler) { 218 if (window.console) { console.warn('GIS: credential arrived with no handler registered'); } 219 return; 220 } 221 $timeout(function () { handler(resp.credential); }); 222 } 223 }); 224 idInitialized = true; 225 } 226 227 function gisReady() { 228 return window.google && google.accounts && google.accounts.oauth2; 229 } 230 231 // Load the GIS library if the page doesn't already include the tag, so One Tap 232 // and the buttons work anywhere the app runs rather than only on pages that 233 // hardcode the script. No-op when it's already present or loaded. 234 function ensureGisScript() { 235 if (gisReady()) { return; } 236 if (document.querySelector("script[src^='https://accounts.google.com/gsi/client']")) { return; } 237 var s = document.createElement('script'); 238 s.src = 'https://accounts.google.com/gsi/client'; 239 s.async = true; 240 s.defer = true; 241 document.head.appendChild(s); 242 } 243 244 // Resolve once the gsi/client script has finished loading. 245 function whenGisReady() { 246 var d = $q.defer(); 247 ensureGisScript(); 248 (function check(attempt) { 249 if (gisReady()) { 250 d.resolve(); 251 } else if (attempt > 100) { // ~10s ceiling 252 d.reject('Google Identity Services (gsi/client) failed to load'); 253 } else { 254 $timeout(function () { check(attempt + 1); }, 100); 255 } 256 })(0); 257 return d.promise; 258 } 259 260 function ensureTokenClient() { 261 if (tokenClient) { return; } 262 tokenClient = google.accounts.oauth2.initTokenClient({ 263 client_id: config.clientId, 264 scope: config.scopes, 265 // The popup callback fires outside Angular's digest, so settle the 266 // pending promise inside $timeout to trigger a safe digest. 267 callback: function (resp) { 268 $timeout(function () { 269 if (resp && resp.access_token) { 270 currentToken = resp.access_token; 271 if (pending) { pending.resolve({ access_token: resp.access_token }); } 272 } else if (pending) { 273 pending.reject(resp && resp.error ? resp.error : 'token_error'); 274 } 275 pending = null; 276 }); 277 }, 278 error_callback: function (err) { 279 if (window.console) { 280 console.warn('GIS token flow error:', err && err.type, err && err.message); 281 } 282 $timeout(function () { 283 if (pending) { pending.reject(err); } 284 pending = null; 285 }); 286 } 287 }); 288 } 289 290 // Draws Google's button into entry.element and, in compat mode, lays the 291 // click-catcher over it. Runs again after a FedCM fallback clears the element. 292 function drawButton(entry) { 293 var element = entry.element; 294 google.accounts.id.renderButton(element, entry.opts); 295 if (entry.onCredential) { return; } 296 297 // Compat mode only: transparent click-catcher over the whole 298 // container (Chrome can render the personalized "Continue as ..." 299 // button taller than a fixed box, and any uncovered strip would 300 // click through to the button underneath). 301 if (window.getComputedStyle(element).position === 'static') { 302 element.style.position = 'relative'; 303 }
304 var overlay = document.createElement('div'); 305 overlay.style.cssText = 'position:absolute;top:0;left:0;' + 306 'width:100%;height:100%;cursor:pointer;z-index:5;background:transparent'; 307 overlay.addEventListener('click', function () { entry.onClick(); }); 308 element.appendChild(overlay); 309 } 310 311 // GIS renders a collapsed/tiny button when the target is hidden (e.g. a 312 // container inside an ng-show/ng-if block that is display:none at link 313 // time). Wait until it actually has width, then render at full size. 314 function drawWhenVisible(entry, done) { 315 (function whenVisible(attempt) { 316 if (entry.element.offsetWidth > 0 || attempt > 300) { 317 drawButton(entry); 318 if (done) { done(); } 319 } else { 320 $timeout(function () { whenVisible(attempt + 1); }, 100); 321 } 322 })(0); 323 } 324 325 function GIS() {} 326 327 // Opens the Google account chooser/popup and resolves with { access_token }. 328 // When GIS is already loaded we call requestAccessToken() synchronously so 329 // the popup stays tied to the user's click gesture (avoids popup blocking). 330 GIS.prototype.login = function () { 331 var d = $q.defer(); 332 pending = d; 333 if (gisReady()) { 334 ensureTokenClient(); 335 tokenClient.requestAccessToken(); 336 } else { 337 whenGisReady().then(function () { 338 ensureTokenClient(); 339 tokenClient.requestAccessToken(); 340 }, function (err) { 341 pending = null; 342 d.reject(err); 343 }); 344 } 345 return d.promise; 346 }; 347 348 // Fetches the signed-in user's profile using the access token. 349 // Resolves with { name, email, picture, ... }. 350 GIS.prototype.getUser = function () { 351 var d = $q.defer(); 352 if (!currentToken) { d.reject('no_access_token'); return d.promise; } 353 $http.get('https://www.googleapis.com/oauth2/v3/userinfo', { 354 headers: { Authorization: 'Bearer ' + currentToken } 355 }).then(function (res) { 356 d.resolve(res.data); 357 }, function (err) { 358 d.reject(err); 359 }); 360 return d.promise; 361 }; 362 363 // Songtree shows the One Tap card everywhere except the home, welcome and 364 // login pages. Matched against the FIRST path segment only, so both the 365 // pretty URLs (/home, /welcome, /login) and the underlying files 366 // (home.php, welcome.php, login.php, home2(welcome).php) are covered 367 // without catching user content further down the path -- a plain substring 368 // test would wrongly exclude an artist named "Homer" at 369 // /artist/123/homer. The site root ("/") is not excluded. 370 // Edit this list to change placement. 371 var ONE_TAP_EXCLUDED_PATHS = ['home', 'welcome', 'login']; 372 373 function oneTapAllowedHere() { 374 var path = (window.location.pathname || '').toLowerCase(); 375 var segment = path.replace(/^\/+/, '').split('/')[0]; 376 for (var i = 0; i < ONE_TAP_EXCLUDED_PATHS.length; i++) { 377 if (segment.indexOf(ONE_TAP_EXCLUDED_PATHS[i]) === 0) { return false; } 378 } 379 return true; 380 } 381 382 // Decodes the payload of an id_token credential (base64url -> JSON) to read 383 // email/name/picture. The backend independently verifies the token, so this 384 // is not a trust boundary â it only fills in the fields the login endpoint 385 // expects alongside the token. 386 GIS.prototype.decodeCredential = function (jwt) { 387 try { 388 var part = jwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'); 389 var pad = part.length % 4; 390 if (pad) { part += new Array(5 - pad).join('='); } 391 return JSON.parse(decodeURIComponent(escape(window.atob(part)))); 392 } catch (e) { 393 if (window.console) { console.log('Failed to decode Google credential', e); } 394 return null; 395 } 396 }; 397 398 // Shows Google's One Tap card (the automatic sign-in prompt shown to 399 // logged-out visitors on arrival, before any button is pressed). `handler` 400 // receives the raw id_token credential. 401 // 402 // Callers are responsible for only calling this when the visitor is logged 403 // out. Google applies its own exponential cooldown after a dismissal, so the 404 // card legitimately won't appear on every load.
405 GIS.prototype.promptOneTap = function (handler) { 406 if (!oneTapAllowedHere()) { return; } 407 oneTapHandler = handler; 408 whenGisReady().then(function () { 409 ensureIdInitialized(); 410 google.accounts.id.prompt(); 411 }, function (err) { 412 if (window.console) { console.log('GIS One Tap unavailable:', err); } 413 }); 414 }; 415 416 GIS.prototype.getToken = function () { return currentToken; }; 417 GIS.prototype.setToken = function (t) { currentToken = t; }; 418 GIS.prototype.getClientId = function () { return config.clientId; }; 419 GIS.prototype.logout = function () { 420 if (currentToken && gisReady()) { 421 google.accounts.oauth2.revoke(currentToken, function () {}); 422 } 423 currentToken = null; 424 }; 425 426 // Renders Google's official "Sign in with Google" button into `element`. 427 // 428 // Preferred mode (onCredential supplied): Google's button runs its own 429 // id-token flow, which presents the compact FedCM account chooser that 430 // n-Track uses, rather than the full-size OAuth consent window the 431 // access-token flow opens. The resulting credential is routed back through 432 // onCredential. This is the flow n-Track has used successfully. 433 // 434 // Compat mode (no onCredential): a transparent click-catcher is overlaid so 435 // the click runs the access-token flow via onClick() instead. This was the 436 // original workaround for the id-token popup failing in Chrome, where 437 // COOP/third-party-cookie restrictions severed the popup's postMessage back 438 // to the opener. FedCM (use_fedcm_for_button) removes that dependency, so 439 // the preferred mode above is no longer subject to it. 440 GIS.prototype.renderButton = function (element, onClick, options, onCredential) { 441 if (onCredential) { credentialHandlers.push({ element: element, handler: onCredential }); } 442 whenGisReady().then(function () { 443 ensureIdInitialized(); 444 445 var opts = angular.extend({ 446 theme: 'outline', 447 size: 'large', 448 type: 'standard', 449 text: 'continue_with', 450 width: 300 451 }, options || {}); 452 opts.click_listener = function () { 453 buttonClickedAt = Date.now(); 454 var notes = document.querySelectorAll('.google-retry-note'); 455 for (var n = 0; n < notes.length; n++) { notes[n].parentNode.removeChild(notes[n]); } 456 }; 457 458 var entry = { element: element, opts: opts, onClick: onClick, onCredential: onCredential }; 459 drawWhenVisible(entry, function () { 460 renderedButtons.push(entry); 461 if (onCredential) { 462 // Note which button was used, so the single global credential 463 // callback can be routed to the right handler. Capture phase and 464 // non-intercepting: Google's button still handles the click. 465 element.addEventListener('click', function () { 466 lastClickedElement = element; 467 }, true); 468 } 469 }); 470 }, function (err) { 471 if (window.console) { console.log('GIS renderButton error:', err); } 472 }); 473 }; 474 475 return new GIS(); 476 }]; 477 }]); 478 479// Attribute directive that drops Google's official rendered button into an element. 480// 481// Preferred usage â let Google's button run its own sign-in (compact FedCM 482// chooser) and hand the credential to an expression, with `credential` (the raw 483// id_token JWT) in scope: 484// <div google-signin-button 485// google-signin-credential="LoginWithGoogleCredential(credential)"></div> 486// (google-signin-credential-fallback is the original name for this attribute and 487// still works; it predates this being the primary path.) 488// 489// Compat usage â if no credential expression is given, a transparent overlay is 490// placed over the button and this expression runs on click instead, which should 491// invoke the access-token flow (GooglePlus.login()): 492// <div google-signin-button="LoginWithGoothenOnboard()"></div> 493// 494// Registered on the shared 'googleplus' module so it's available anywhere the GIS 495// wrapper is loaded (both the Songtree app and the login page modules depend on it). 496angular.module('googleplus').directive('googleSigninButton', ['GooglePlus', function (GooglePlus) { 497 return { 498 restrict: 'A', 499 link: function (scope, element, attrs) { 500 var credentialExpr = attrs.googleSigninCredential || attrs.googleSigninCredentialFallback; 501 // Both callbacks fire outside Angular (Google's callback / a DOM listener), 502 // so settle them with $apply. 503 GooglePlus.renderButton(element[0], function () { 504 scope.$apply(function () { scope.$eval(attrs.googleSigninButton); }); 505 }, scope.$eval(attrs.googleSigninOptions), credentialExpr ? function (credential) { 506 scope.$apply(function () { scope.$eval(credentialExpr, { credential: credential }); }); 507 } : null); 508 } 509 }; 510}]);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.