Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 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 | 2x 2x 2x 2x 2x 2x 2x 2x 2x 325x 325x 2x 2x 2x 266x 266x 266x 91x 91x 2x 14x 2x 2x 2x 91x 91x 91x 91x 91x 91x 91x 2x 34x 34x 34x 34x 34x 2x 2x 2x 265x 265x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 46x 46x 46x 46x 1x 1x 1x 1x 45x 45x 45x 45x 45x 45x 46x 46x 46x 46x 46x 46x 1x 1x 1x 1x 1x 1x 1x 46x 43x 43x 46x 3x 3x 3x 3x 3x 3x 39x 46x 1x 1x 1x 1x 38x 46x 46x 46x 38x 38x 38x 38x 2x 226x 226x 226x 226x 226x 226x 226x | /**
* Signing in to Google, for the files a key cannot reach (SPECS §24.7).
*
* A Google API key opens a *public* Drive file and nothing more: the Drive API
* refuses a request without one even for a file anyone can read, and it refuses
* one *with* a key for a file that is not public. The second half of that
* sentence is the whole reason this module exists. A private Drive file, and a
* private GCS object read through the JSON API, need `Authorization: Bearer
* <token>` on every request, and the only way a page with no backend obtains one
* is for the reader to sign in to their own Google account, in their own
* browser, through Google's own script.
*
* ## What the site supplies, and what the reader supplies
*
* The site supplies a **client id**: a registration in a Google Cloud project
* saying "this origin may ask my users for these scopes". It is not a secret —
* it ships in the HTML of every site that uses this flow, and it authorises
* nothing on its own — and it is bound to *an origin*, which is why it is a
* property of the deployment and of nothing else: the build takes it from the
* `GOOGLE_CLIENT_ID` variable (`config/siteDefaults.ts`, docs/deploy.md), a
* fork served from somewhere else registers its own, and no reader is asked
* for one. There used to be a box for it in the dialog, and it was a question
* every reader had to wonder about that only a maintainer could answer.
*
* The reader supplies the **grant**, and it is much wider than the client id
* suggests. `drive.readonly` is read access to **all of their Drive**, where a
* bucket scope (§20.3) is one prefix of one bucket. The dialog says so in those
* words, because a consent screen buries it under a product name and because
* this is the one place in the application where a reader hands over something
* they cannot see the edges of.
*
* ## What is kept, and for how long
*
* Nothing in storage: the client id is in the page. The token, **in memory
* only and never written** (§24.8) — it is held by `s3/credentials.ts`'s bearer registry, which is a
* module-level `Map` a reload empties, and it expires inside the hour anyway.
* Storing it would buy a reader nothing and would cost the stored-data screen
* the one thing it cannot offer for a short-lived secret: an honest answer.
* Nothing here asks for an identity scope, so the app never learns who signed
* in; what it can say is *signed in, until 14:32*, which is the whole of what a
* reader needs to interpret the next `401`.
*
* ## The script is fetched on a click, and on no other occasion
*
* `https://accounts.google.com/gsi/client` is a third-party script, and §11's
* rule for third-party requests is that they happen on a gesture. Loading it at
* module scope would tell Google that somebody opened the viewer — every time
* anybody opened the viewer, including the readers who only ever drop a local
* file. So the load is inside {@link signIn}, behind {@link loadGis}'s single
* promise, and `test/dom/google-signin.test.ts` asserts that importing this
* module and building the dialog append no script tag.
*
* That same seam is what makes the flow testable at all. Google's script cannot
* load in a test environment — nor, as it happens, in the sandbox this was
* written in — so what the flow talks to is a {@link TokenClientFactory}, which
* is Google's `initTokenClient` in the browser and a stub in a test. The
* alternative was a module nothing could assert anything about, in the one place
* in the app that handles a credential.
*
* ## Where this is absent, and why
*
* The **standalone build** (§19) has an opaque origin: there is no origin to
* register, so there is no client id that could be made to work. The **desktop
* build** serves itself from `app://viewer` (`electron/protocol.cts`), and
* Google's OAuth client registration accepts `http`/`https` JavaScript origins
* only — a custom scheme cannot be registered, so the token client would refuse
* the request whatever the page's Content-Security-Policy allowed. Both are
* §8.5's case — a control that cannot keep its promise is absent rather than
* disabled — and {@link GOOGLE_SIGN_IN_AVAILABLE} is the one place that is
* decided.
*/
import { clearBearer, setBearer } from '../s3/credentials'
import { TARGET } from '../version'
import { SITE_DEFAULTS } from '../config/siteDefaults'
/**
* What the token is asked to be good for.
*
* Two scopes because one sign-in serves both Google hosts this viewer reads —
* Drive through `www.googleapis.com`, GCS through the JSON API on
* `storage.googleapis.com` — and asking for them separately would mean two
* consent screens for one gesture. Both are read-only, and deliberately so:
* §13's "the app never modifies a file it opened" should be true of the *grant*
* as well as of the code, and a `drive.file`-shaped narrowing is not available
* here because the file is named by a URL the reader pasted rather than picked
* through Google's own picker.
*
* There is no `userinfo` scope, so this app never learns the reader's address.
* It would make the dialog read better — *signed in as …* — and that is a
* second grant asked for a label, which is not a trade this app makes.
*/
export const GOOGLE_SCOPE =
'https://www.googleapis.com/auth/drive.readonly https://www.googleapis.com/auth/devstorage.read_only'
/**
* The hosts one sign-in is registered for.
*
* Exact hosts, not the `*.googleapis.com` glob `setBearer` would accept. A
* bearer token is a credential and every host it is sent to receives it; the two
* below are the two endpoints §24.7 names as taking one, and `oauth2.googleapis.com`
* or any other future subdomain of the same parent has no business being handed
* the reader's Drive token because a URL happened to be typed at it.
*/
export const GOOGLE_HOSTS = ['www.googleapis.com', 'storage.googleapis.com'] as const
/** Google Identity Services, fetched on a click and never on load. */
export const GIS_SCRIPT = 'https://accounts.google.com/gsi/client'
/**
* Whether this build can sign in at all.
*
* See the header: the standalone build has no origin and the desktop build's
* `app://viewer` is not an origin Google's console will register. The web build
* is the one that can, and this constant is what the dialog and the error view
* both ask rather than each spelling out a target comparison of its own.
*/
export const GOOGLE_SIGN_IN_AVAILABLE = TARGET === 'web'
/** What the account section and the error view draw themselves from. */
export interface GoogleState {
/** This site was built with a client id, so signing in is a thing that could work. */
configured: boolean
/** A token is held **and** has not expired. */
signedIn: boolean
/**
* Epoch milliseconds, and it **outlives the token**.
*
* A past `expiresAt` with `signedIn: false` is the state that says *this page
* had a token and it ran out*, which is a different sentence from *this page
* has never signed in* — the first is answered by "sign in again" beside a
* `401` the reader has just met, and the second by an explanation of what
* signing in is for. `null` only before the first sign-in and after
* {@link signOut}.
*/
expiresAt: number | null
}
/**
* Google's three shapes under local names, so a caller — a test's stub, above
* all — does not have to reach into a global namespace to write one down.
*/
export type TokenResponse = google.accounts.oauth2.TokenResponse
export type TokenClientConfig = google.accounts.oauth2.TokenClientConfig
export type TokenClient = google.accounts.oauth2.TokenClient
/**
* How a token client is obtained: Google's own, or a test's.
*
* Allowed to answer synchronously or with a promise, because the two
* implementations genuinely differ — the real one has a script to fetch first,
* a stub has nothing to wait for and should not have to pretend it does.
*/
export type TokenClientFactory = (
config: google.accounts.oauth2.TokenClientConfig,
) => google.accounts.oauth2.TokenClient | Promise<google.accounts.oauth2.TokenClient>
// ---------------------------------------------------------------------------
// The client id, which is the site's
// ---------------------------------------------------------------------------
/**
* The client id a sign-in goes to: the one this site was built with
* (`config/siteDefaults.ts`), or `null` on a site built without one.
*
* Nothing a reader can enter and nothing this browser keeps. A client id is a
* registration of *this site's address* with one Google project, so it is the
* deployment's to provide and no reader's to know; the dialog offers the
* sign-in it makes possible, and on a site without one says so.
*/
export function readClientId(): string | null {
return SITE_DEFAULTS.googleClientId
}
// ---------------------------------------------------------------------------
// The token, which is not stored
// ---------------------------------------------------------------------------
let token: string | null = null
let expiresAt: number | null = null
const listeners = new Set<() => void>()
function announce(): void {
for (const listener of listeners) listener()
}
/**
* Drop a token that has run out, and say whether one was dropped.
*
* Checked when the state is asked for rather than on a timer, for
* `bearerFor`'s reason: a timer is a thing to cancel when the page goes away,
* and the only moment the answer matters is the moment somebody asks. The
* bearer registry expires its own copy on the way out too — this is the half
* that makes the *dialog* stop claiming a token exists, and that keeps
* {@link GoogleState.expiresAt} standing afterwards so the offer beside the next
* `401` can read "sign in again".
*/
function expire(): boolean {
if (token === null || expiresAt === null || expiresAt > Date.now()) return false
token = null
for (const host of GOOGLE_HOSTS) clearBearer(host)
return true
}
/**
* What this page can say about Google right now.
*
* Recomputed on every call rather than cached, because the answer changes
* without anybody touching this module: the token expires on its own. The
* client id does not — it is the one the site was built with.
*/
export function googleState(): GoogleState {
// Announced from a *getter*, which is worth one sentence: the listeners this
// wakes call `googleState()` in turn, and the second call finds `token` already
// null and returns false. One notification, no recursion — and without it a
// dialog left open across an expiry would go on displaying a time that has
// passed.
if (expire()) announce()
return {
configured: readClientId() !== null,
signedIn: token !== null,
expiresAt,
}
}
/** Hear about a sign-in, a sign-out or an expiry. */
export function subscribeGoogle(listener: () => void): () => void {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
// ---------------------------------------------------------------------------
// Google's script, and the seam in front of it
// ---------------------------------------------------------------------------
let loading: Promise<void> | null = null
let factory: TokenClientFactory | null = null
/**
* Use `made` instead of Google's `initTokenClient`, or `null` to put it back.
*
* The injection point the DOM tests drive the whole flow through, and the reason
* this module can be asserted about in an environment that has no network and no
* Google. It is exported from the module rather than taken as a constructor
* parameter because the thing that signs in is not an object anybody constructs:
* it is a function the dialog and the error view both call, and threading a
* factory from each of them to here would put a test's concern into two
* production call sites.
*/
export function setTokenClientFactory(made: TokenClientFactory | null): void {
factory = made
}
/**
* Fetch Google Identity Services, once.
*
* The promise is memoised so that two clicks are one script, and it is cleared
* on failure so that a reader whose network dropped can press the button again
* rather than being told forever that the script "is loading".
*/
function loadGis(): Promise<void> {
if (loading !== null) return loading
loading = new Promise<void>((resolve, reject) => {
const script = document.createElement('script')
script.src = GIS_SCRIPT
script.async = true
script.defer = true
script.addEventListener('load', () => resolve())
script.addEventListener('error', () => {
loading = null
script.remove()
reject(
new Error(
`${GIS_SCRIPT} could not be loaded, so there is nothing to sign in with. A network ` +
'that blocks accounts.google.com blocks this feature entirely; a public Drive file ' +
'still opens with the API key alone.',
),
)
})
document.head.appendChild(script)
})
return loading
}
/** Google's own token client, after its script has arrived. */
async function googleTokenClient(
config: google.accounts.oauth2.TokenClientConfig,
): Promise<google.accounts.oauth2.TokenClient> {
await loadGis()
// `typeof` rather than a truth test: the name is not bound until the script
// runs, and reading an unbound identifier is a `ReferenceError` rather than
// `undefined`.
if (typeof google === 'undefined' || typeof google.accounts?.oauth2?.initTokenClient !== 'function') {
throw new Error(
'Google Identity Services loaded but did not install its token client. This is Google’s ' +
'script changing shape under a page that pins no version of it; a public Drive file ' +
'still opens with the API key alone.',
)
}
return google.accounts.oauth2.initTokenClient(config)
}
// ---------------------------------------------------------------------------
// The flow
// ---------------------------------------------------------------------------
/**
* An hour, which is what a Google access token lives for.
*
* Used only when a response arrives without a readable `expires_in`, and the
* fallback matters more than it looks: an unparseable value would become a `NaN`
* expiry, and `NaN > Date.now()` is false in one direction and `NaN <= now` is
* false in the other — a token that neither expires nor is ever usable,
* depending on which comparison asked. A wrong-but-finite hour fails the way
* every expiry fails, which is a `401` with an offer beside it.
*/
const DEFAULT_LIFETIME_SECONDS = 3600
/**
* Ask Google for a token, and hold it for the two hosts it is good for.
*
* Rejects rather than reporting quietly, because every caller is a button: the
* dialog shows the message under the button and the error view puts it where the
* refusal was. The messages name the fix, as §10 requires of a refusal, and the
* three that can happen before Google is ever contacted — no client id, a build
* that cannot register an origin, a script that will not load — are worth
* distinguishing precisely because they all look like "sign in did nothing".
*/
export async function signIn(): Promise<void> {
if (!GOOGLE_SIGN_IN_AVAILABLE) {
throw new Error(
'This build cannot sign in to Google: an OAuth client id is registered against an origin, ' +
'and neither the single-file build (which has none) nor the desktop build (which serves ' +
'itself from app://viewer, a scheme Google’s console will not accept) has one to register.',
)
}
const clientId = readClientId()
if (clientId === null) {
throw new Error(
'Signing in to Google is not set up on this site: it needs a Google client id registered ' +
'for its address, which the site’s maintainer provides as GOOGLE_CLIENT_ID ' +
'(docs/deploy.md).',
)
}
// Settled from Google's callback, which is the only channel the token client
// has: `requestAccessToken` returns nothing, and a refusal at the consent
// screen arrives as a field on the same object the token would have been on.
let settle!: (response: google.accounts.oauth2.TokenResponse) => void
let fail!: (error: Error) => void
const answer = new Promise<google.accounts.oauth2.TokenResponse>((resolve, reject) => {
settle = resolve
fail = reject
})
const make = factory ?? googleTokenClient
const client = await make({
client_id: clientId,
scope: GOOGLE_SCOPE,
callback: (response) => settle(response),
error_callback: (error) =>
fail(
new Error(
`Google did not complete the sign-in (${error.type ?? 'unknown'}${
error.message === undefined ? '' : `: ${error.message}`
}). A blocked popup is the usual cause; allow popups for this page and try again.`,
),
),
})
client.requestAccessToken()
const response = await answer
if (typeof response.error === 'string' && response.error !== '') {
throw new Error(
`Google refused the sign-in: ${response.error}${
response.error_description === undefined ? '' : ` — ${response.error_description}`
}.`,
)
}
const granted = response.access_token
if (typeof granted !== 'string' || granted === '') {
throw new Error(
'Google answered the sign-in without a token, so nothing was granted. Trying again is the ' +
'only thing to do about it.',
)
}
const seconds = Number(response.expires_in)
const lifetime = Number.isFinite(seconds) && seconds > 0 ? seconds : DEFAULT_LIFETIME_SECONDS
const until = Date.now() + lifetime * 1000
// Both hosts from one response, because one sign-in produced one token that
// covers both scopes. Registering them one at a time is the arrangement where
// somebody forgets the second and a `gs://` object goes on failing after a
// sign-in the reader watched succeed.
for (const host of GOOGLE_HOSTS) setBearer(host, granted, until)
token = granted
expiresAt = until
announce()
}
/**
* Drop the token, and tell Google to forget the grant.
*
* The local half is what matters and happens first: the bearers go, so the next
* request for a private Drive file is refused rather than sent with a credential
* the reader has just disclaimed. The revoke is best effort — it is a network
* call to a script that may not be loaded, on a page that may be closing — and
* nothing here waits for it or reports its failure, because a reader who pressed
* *sign out* has already got what they asked for from this browser's side.
*/
export function signOut(): void {
const held = token
token = null
expiresAt = null
for (const host of GOOGLE_HOSTS) clearBearer(host)
if (held !== null && typeof google !== 'undefined' && typeof google.accounts?.oauth2?.revoke === 'function') {
try {
google.accounts.oauth2.revoke(held)
} catch {
/* best effort: the grant is Google's record, the token is already gone here */
}
}
announce()
}
|