@mirrormedia/lilith-google-auth 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/@types/google.d.ts +21 -0
- package/@types/index.d.ts +11 -0
- package/@types/log.d.ts +31 -0
- package/@types/mini-app.d.ts +8 -0
- package/@types/password-guard.d.ts +15 -0
- package/@types/password-plugin.d.ts +30 -0
- package/@types/redirect.d.ts +5 -0
- package/@types/session.d.ts +20 -0
- package/@types/signin-page.d.ts +8 -0
- package/@types/state-cookie.d.ts +12 -0
- package/@types/types.d.ts +69 -0
- package/@types/with-google-auth.d.ts +46 -0
- package/README.md +237 -0
- package/lib/google.js +59 -0
- package/lib/index.js +57 -0
- package/lib/log.js +68 -0
- package/lib/mini-app.js +267 -0
- package/lib/password-guard.js +66 -0
- package/lib/password-plugin.js +86 -0
- package/lib/redirect.js +21 -0
- package/lib/session.js +59 -0
- package/lib/signin-page.js +55 -0
- package/lib/state-cookie.js +64 -0
- package/lib/types.js +5 -0
- package/lib/with-google-auth.js +54 -0
- package/package.json +46 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export declare type GoogleIdentity = {
|
|
2
|
+
email: string | null;
|
|
3
|
+
emailVerified: boolean;
|
|
4
|
+
hd: string | null;
|
|
5
|
+
nonce: string | null;
|
|
6
|
+
};
|
|
7
|
+
export declare type GoogleClient = {
|
|
8
|
+
buildAuthUrl(args: {
|
|
9
|
+
state: string;
|
|
10
|
+
nonce: string;
|
|
11
|
+
hdHint?: string;
|
|
12
|
+
}): string;
|
|
13
|
+
/** Exchanges the code and verifies the ID token. Throws on any failure. */
|
|
14
|
+
exchangeCode(code: string): Promise<GoogleIdentity>;
|
|
15
|
+
};
|
|
16
|
+
export declare type GoogleClientConfig = {
|
|
17
|
+
clientId: string;
|
|
18
|
+
clientSecret: string;
|
|
19
|
+
callbackUrl: string;
|
|
20
|
+
};
|
|
21
|
+
export declare function createGoogleClient(config: GoogleClientConfig): GoogleClient;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { createGoogleAuthMiniApp } from './mini-app';
|
|
2
|
+
export type { GoogleAuthDependencies } from './mini-app';
|
|
3
|
+
export { formatLogEntry } from './log';
|
|
4
|
+
export type { LogEntry } from './log';
|
|
5
|
+
export { PASSWORD_MUTATION_FIELD, createPasswordLoginBlockPlugin, documentSelectsPasswordLogin, } from './password-plugin';
|
|
6
|
+
export type { PasswordLoginBlockPlugin } from './password-plugin';
|
|
7
|
+
export { ERROR_MESSAGES } from './signin-page';
|
|
8
|
+
export type { GoogleAuthErrorCode, GoogleAuthLogEvent, GoogleAuthOptions, KeystoneContext, KeystoneRequestContext, KeystoneSessionStrategy, } from './types';
|
|
9
|
+
export type { GoogleClient, GoogleIdentity } from './google';
|
|
10
|
+
export { withGoogleAuth } from './with-google-auth';
|
|
11
|
+
export type { KeystoneConfigLike, WithGoogleAuthOptions, } from './with-google-auth';
|
package/@types/log.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { GoogleAuthLogEvent } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Single-line JSON envelope for Cloud Logging. `severity` lets Cloud Logging
|
|
4
|
+
* parse the entry as `jsonPayload` instead of a multi-line `textPayload`, and
|
|
5
|
+
* an `ERROR` entry whose `message` is a stack trace is picked up by Error
|
|
6
|
+
* Reporting. Shape is shared with the upcoming lilith-core password-login
|
|
7
|
+
* change (separate PR); this file intentionally has no dependency on
|
|
8
|
+
* `@twreporter/errors`.
|
|
9
|
+
*/
|
|
10
|
+
export declare type LogEntry = {
|
|
11
|
+
severity: 'INFO' | 'WARNING' | 'ERROR';
|
|
12
|
+
message: string;
|
|
13
|
+
} & Record<string, unknown>;
|
|
14
|
+
/**
|
|
15
|
+
* Maps a login event to a log entry. Fields stay top-level (no nesting) so a
|
|
16
|
+
* Logs Explorer query can filter on e.g. `jsonPayload.userId` directly.
|
|
17
|
+
*/
|
|
18
|
+
export declare function formatLogEntry(event: GoogleAuthLogEvent): LogEntry;
|
|
19
|
+
/**
|
|
20
|
+
* Maps an unexpected throw (callback catch-all, or a caller-supplied logger
|
|
21
|
+
* throwing) to an ERROR entry. `message` is the stack trace when available so
|
|
22
|
+
* Error Reporting can group and display it; a non-Error thrown value falls
|
|
23
|
+
* back to its string form.
|
|
24
|
+
*/
|
|
25
|
+
export declare function formatErrorEntry(err: unknown, context: {
|
|
26
|
+
type: 'google-login';
|
|
27
|
+
stage: string;
|
|
28
|
+
email?: string | null;
|
|
29
|
+
}): LogEntry;
|
|
30
|
+
/** Prints `entry` as a single JSON line: `console.error` for ERROR, `console.log` otherwise. */
|
|
31
|
+
export declare function emitLogEntry(entry: LogEntry): void;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import type { GoogleClient } from './google';
|
|
3
|
+
import type { GoogleAuthOptions } from './types';
|
|
4
|
+
export declare type GoogleAuthDependencies = {
|
|
5
|
+
/** Test seam; production callers omit this. */
|
|
6
|
+
google?: GoogleClient;
|
|
7
|
+
};
|
|
8
|
+
export declare function createGoogleAuthMiniApp(options: GoogleAuthOptions, deps?: GoogleAuthDependencies): Router;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { RequestHandler } from 'express';
|
|
2
|
+
/** Field name of Keystone's password mutation for listKey 'User'. */
|
|
3
|
+
export declare const PASSWORD_MUTATION_PATTERN: RegExp;
|
|
4
|
+
/**
|
|
5
|
+
* True when a GraphQL request body (single or batched) selects the password
|
|
6
|
+
* mutation. Aliases cannot hide it because the field name must still appear.
|
|
7
|
+
*/
|
|
8
|
+
export declare function requestUsesPasswordLogin(body: unknown): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Express handlers that reject the password mutation with 403. Mount on the
|
|
11
|
+
* GraphQL path only when password login is disabled. The 500mb limit mirrors
|
|
12
|
+
* the host packages' own body parser so large DraftJS payloads are not
|
|
13
|
+
* rejected here with 413.
|
|
14
|
+
*/
|
|
15
|
+
export declare function createPasswordLoginGuard(): RequestHandler[];
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { DocumentNode } from 'graphql';
|
|
2
|
+
/** Field name of Keystone's password mutation for listKey 'User'. */
|
|
3
|
+
export declare const PASSWORD_MUTATION_FIELD = "authenticateUserWithPassword";
|
|
4
|
+
/**
|
|
5
|
+
* True when any mutation operation in the document selects the password
|
|
6
|
+
* mutation at top level, directly or through a fragment spread. Working on the
|
|
7
|
+
* parsed document instead of the raw query string is what makes this immune to
|
|
8
|
+
* the multipart bypass: Keystone mounts `graphqlUploadExpress` after
|
|
9
|
+
* `extendExpressApp`, so an HTTP-layer guard never sees the operation carried
|
|
10
|
+
* in a multipart `operations` field.
|
|
11
|
+
*/
|
|
12
|
+
export declare function documentSelectsPasswordLogin(document: DocumentNode): boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Structural view of the slice of Apollo Server 4's plugin API this package
|
|
15
|
+
* uses. Typed by hand so the package does not depend on @apollo/server; the
|
|
16
|
+
* shape is assignable to `ApolloServerPlugin` where the host needs it.
|
|
17
|
+
*/
|
|
18
|
+
export declare type PasswordLoginBlockPlugin = {
|
|
19
|
+
requestDidStart(): Promise<{
|
|
20
|
+
didResolveOperation(requestContext: {
|
|
21
|
+
document: DocumentNode;
|
|
22
|
+
}): Promise<void>;
|
|
23
|
+
}>;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Apollo Server plugin that rejects the password mutation with 403. Add it to
|
|
27
|
+
* `config.graphql.apolloConfig.plugins` whenever the password kill switch is
|
|
28
|
+
* on; the mini-app's HTTP guard alone cannot see multipart requests.
|
|
29
|
+
*/
|
|
30
|
+
export declare function createPasswordLoginBlockPlugin(): PasswordLoginBlockPlugin;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Request, Response } from 'express';
|
|
2
|
+
import type { KeystoneContext } from './types';
|
|
3
|
+
export declare type SessionUser = {
|
|
4
|
+
id: string;
|
|
5
|
+
email: string | null;
|
|
6
|
+
name: string | null;
|
|
7
|
+
role: string | null;
|
|
8
|
+
};
|
|
9
|
+
export declare type SigninResult = {
|
|
10
|
+
ok: true;
|
|
11
|
+
user: SessionUser;
|
|
12
|
+
} | {
|
|
13
|
+
ok: false;
|
|
14
|
+
reason: 'no_user' | 'session';
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Looks up the User by email and issues the same session cookie Keystone's
|
|
18
|
+
* password login would. No row is ever created here.
|
|
19
|
+
*/
|
|
20
|
+
export declare function signInByEmail(keystoneContext: KeystoneContext, req: Request, res: Response, email: string): Promise<SigninResult>;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { GoogleAuthErrorCode } from './types';
|
|
2
|
+
export declare const ERROR_MESSAGES: Record<GoogleAuthErrorCode, string>;
|
|
3
|
+
export declare type SigninPageOptions = {
|
|
4
|
+
passwordLoginEnabled: boolean;
|
|
5
|
+
from?: string;
|
|
6
|
+
error?: string;
|
|
7
|
+
};
|
|
8
|
+
export declare function renderSigninPage(options: SigninPageOptions): string;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare const STATE_COOKIE_NAME = "lilith-google-auth-state";
|
|
2
|
+
export declare const STATE_TTL_SECONDS = 300;
|
|
3
|
+
export declare type AuthState = {
|
|
4
|
+
state: string;
|
|
5
|
+
nonce: string;
|
|
6
|
+
from: string;
|
|
7
|
+
/** Absolute expiry in ms since epoch. */
|
|
8
|
+
exp: number;
|
|
9
|
+
};
|
|
10
|
+
export declare function createAuthState(from: string, now?: number): AuthState;
|
|
11
|
+
export declare function sealAuthState(state: AuthState, secret: string): string;
|
|
12
|
+
export declare function unsealAuthState(sealed: string | undefined, secret: string, now?: number): AuthState | undefined;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { Request, Response } from 'express';
|
|
2
|
+
/**
|
|
3
|
+
* Narrow structural views of Keystone's context. Keeping these local means the
|
|
4
|
+
* package does not pin @keystone-6/core; consumers cast their generated
|
|
5
|
+
* context (see README).
|
|
6
|
+
*/
|
|
7
|
+
export declare type KeystoneSessionStrategy = {
|
|
8
|
+
start(args: {
|
|
9
|
+
data: {
|
|
10
|
+
listKey: string;
|
|
11
|
+
itemId: string;
|
|
12
|
+
};
|
|
13
|
+
context: KeystoneRequestContext;
|
|
14
|
+
}): Promise<string | undefined>;
|
|
15
|
+
};
|
|
16
|
+
export declare type KeystoneRequestContext = {
|
|
17
|
+
sessionStrategy?: KeystoneSessionStrategy;
|
|
18
|
+
};
|
|
19
|
+
export declare type KeystoneListQuery = {
|
|
20
|
+
findOne(args: {
|
|
21
|
+
where: Record<string, unknown>;
|
|
22
|
+
query?: string;
|
|
23
|
+
}): Promise<Record<string, unknown> | null>;
|
|
24
|
+
};
|
|
25
|
+
export declare type KeystoneContext = {
|
|
26
|
+
sudo(): {
|
|
27
|
+
query: Record<string, KeystoneListQuery>;
|
|
28
|
+
};
|
|
29
|
+
withRequest(req: Request, res: Response): Promise<KeystoneRequestContext>;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Field names mirror lilith-core's login-logging plugin so both password and
|
|
33
|
+
* Google logins answer the same log query.
|
|
34
|
+
*/
|
|
35
|
+
export declare type GoogleAuthLogEvent = {
|
|
36
|
+
type: 'google-login';
|
|
37
|
+
outcome: 'success' | 'failure';
|
|
38
|
+
reason?: GoogleAuthErrorCode;
|
|
39
|
+
/** ISO 8601, e.g. 2026-09-09T04:05:06.789Z */
|
|
40
|
+
timestamp: string;
|
|
41
|
+
userId: string | null;
|
|
42
|
+
email: string | null;
|
|
43
|
+
name: string | null;
|
|
44
|
+
role: string | null;
|
|
45
|
+
ipAddress: string | null;
|
|
46
|
+
userAgent: string | null;
|
|
47
|
+
};
|
|
48
|
+
export declare type GoogleAuthErrorCode = 'state' | 'token' | 'domain' | 'unverified_email' | 'no_user' | 'session';
|
|
49
|
+
export declare type GoogleAuthOptions = {
|
|
50
|
+
keystoneContext: KeystoneContext;
|
|
51
|
+
clientId: string;
|
|
52
|
+
clientSecret: string;
|
|
53
|
+
/** Absolute URL of the callback route, e.g. https://cms.example/auth/google/callback */
|
|
54
|
+
callbackUrl: string;
|
|
55
|
+
/** Google `hd` claim allow-list, e.g. ['mirrormedia.mg', 'readr.tw'] */
|
|
56
|
+
allowedDomains: string[];
|
|
57
|
+
/** Signs the short-lived state cookie. Reuse SESSION_SECRET. */
|
|
58
|
+
stateSecret: string;
|
|
59
|
+
/** Default true. When false, password login is hidden and its mutation is rejected. */
|
|
60
|
+
passwordLoginEnabled?: boolean;
|
|
61
|
+
/**
|
|
62
|
+
* Path the host serves GraphQL on; must equal `config.graphql.path`.
|
|
63
|
+
* Default '/api/graphql'. Only used to mount the password HTTP guard.
|
|
64
|
+
*/
|
|
65
|
+
graphqlPath?: string;
|
|
66
|
+
/** Where to send the user after login when no `from` is present. Default '/'. */
|
|
67
|
+
signinRedirectDefault?: string;
|
|
68
|
+
logger?: (event: GoogleAuthLogEvent) => void;
|
|
69
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { GoogleAuthOptions } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* The slice of Keystone's config this wrapper reads or rebuilds. Declared
|
|
4
|
+
* structurally so the package still does not depend on @keystone-6/core; the
|
|
5
|
+
* index signatures let a real `KeystoneConfig` (a type alias, so it carries an
|
|
6
|
+
* implicit index signature) satisfy the constraint while keeping every key the
|
|
7
|
+
* host set.
|
|
8
|
+
*
|
|
9
|
+
* `app` and `context` are `any` on purpose: `extendExpressApp` is a property
|
|
10
|
+
* with a function type, so under `strictFunctionTypes` a narrower parameter
|
|
11
|
+
* type here would make Keystone's own `(app: Express, context: KeystoneContext)`
|
|
12
|
+
* signature unassignable.
|
|
13
|
+
*/
|
|
14
|
+
export declare type KeystoneConfigLike = {
|
|
15
|
+
[key: string]: unknown;
|
|
16
|
+
server?: {
|
|
17
|
+
[key: string]: unknown;
|
|
18
|
+
extendExpressApp?: (app: any, context: any) => void | Promise<void>;
|
|
19
|
+
};
|
|
20
|
+
graphql?: {
|
|
21
|
+
[key: string]: unknown;
|
|
22
|
+
apolloConfig?: {
|
|
23
|
+
[key: string]: unknown;
|
|
24
|
+
plugins?: unknown[];
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* `keystoneContext` is supplied by the wrapper from `extendExpressApp`'s own
|
|
30
|
+
* argument; `isEnabled` lets a consumer pass an env block straight through
|
|
31
|
+
* (`{ ...envVar.googleAuth, stateSecret: envVar.session.secret }`).
|
|
32
|
+
*/
|
|
33
|
+
export declare type WithGoogleAuthOptions = Omit<GoogleAuthOptions, 'keystoneContext'> & {
|
|
34
|
+
/** Default true. `false` returns the config untouched. */
|
|
35
|
+
isEnabled?: boolean;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Wraps a Keystone config with Google sign-in: mounts the mini-app at the head
|
|
39
|
+
* of `server.extendExpressApp` and, when the password kill switch is on, adds
|
|
40
|
+
* the Apollo block plugin the mini-app's HTTP guard cannot replace.
|
|
41
|
+
*
|
|
42
|
+
* Mounting first is what lets the mini-app answer `/signin` before the Admin
|
|
43
|
+
* UI middleware claims it; it also means the mini-app stamps its own
|
|
44
|
+
* `X-Robots-Tag` rather than inheriting the host's.
|
|
45
|
+
*/
|
|
46
|
+
export declare function withGoogleAuth<C extends KeystoneConfigLike>(config: C, options: WithGoogleAuthOptions): C;
|
package/README.md
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# @mirrormedia/lilith-google-auth
|
|
2
|
+
|
|
3
|
+
Google Workspace sign-in for Lilith Keystone CMS packages, delivered as an Express mini-app. It issues Keystone's own session cookie, so access control, `session.data`, and the Admin UI behave exactly as they do after a password login.
|
|
4
|
+
|
|
5
|
+
- Custom `/signin` page with a "使用 Google 帳號登入" button. Password login stays reachable via `/signin?password=1` unless disabled.
|
|
6
|
+
- `/auth/google` starts the OAuth code flow; `/auth/google/callback` verifies the ID token (`hd` domain allow-list, `email_verified`, nonce) and signs in the matching `User` row. No user is ever created.
|
|
7
|
+
- Optional password-login kill switch: hides the password page and rejects the `authenticateUserWithPassword` mutation with 403.
|
|
8
|
+
|
|
9
|
+
No dependency on `@keystone-6/core`: Keystone is typed structurally, so any package can adopt it without changing its `@mirrormedia/lilith-core` version line.
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
`withGoogleAuth` is the recommended integration: one wrapper around the exported config. Name the existing config and wrap it on the way out, so adopting the feature leaves the config body untouched and its indentation unchanged.
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { withGoogleAuth } from '@mirrormedia/lilith-google-auth'
|
|
17
|
+
|
|
18
|
+
const keystoneConfig = withAuth(
|
|
19
|
+
config({
|
|
20
|
+
/* unchanged */
|
|
21
|
+
})
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
export default withGoogleAuth(keystoneConfig, {
|
|
25
|
+
...envVar.googleAuth,
|
|
26
|
+
stateSecret: envVar.session.secret,
|
|
27
|
+
})
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
What it does:
|
|
31
|
+
|
|
32
|
+
- `options.isEnabled === false` returns the same `config` object, untouched. An env block whose `isEnabled` is derived from `GOOGLE_AUTH_CLIENT_ID` therefore turns the feature off without a conditional at the call site.
|
|
33
|
+
- Otherwise it rebuilds `server.extendExpressApp` so the mini-app is mounted **first**, then awaits the host's original hook. Mounting first is what lets `/signin` be answered before Keystone's Admin UI middleware claims it.
|
|
34
|
+
- With `passwordLoginEnabled: false` it also prepends `createPasswordLoginBlockPlugin()` to `graphql.apolloConfig.plugins`, preserving every other `graphql` and `apolloConfig` key (`cache`, existing plugins, ...). With password login enabled, `graphql` is left exactly as it was.
|
|
35
|
+
|
|
36
|
+
`WithGoogleAuthOptions` is `GoogleAuthOptions` without `keystoneContext` (the wrapper passes `extendExpressApp`'s own `context`) plus the optional `isEnabled`. The Keystone config is typed structurally (`KeystoneConfigLike`), so the wrapper still imports nothing from `@keystone-6/core` and returns the config type it was given.
|
|
37
|
+
|
|
38
|
+
Because the mini-app runs before the host's middleware, it sets `X-Robots-Tag: noindex, nofollow, noimageindex` on the pages it serves itself (`/signin`, `/auth/google`, and the callback, on every outcome) instead of relying on a host-side header middleware. Requests it passes through are left to the host.
|
|
39
|
+
|
|
40
|
+
The mini-app is constructed when Keystone calls `extendExpressApp`, and throws there (prefix `[google-auth]`) when the options cannot produce a working flow: an empty `clientId`, `clientSecret` or `stateSecret`, a `stateSecret` shorter than 32 characters, an empty `allowedDomains`, or a `callbackUrl` that is not an absolute http(s) URL whose path is usable as an Express route.
|
|
41
|
+
|
|
42
|
+
### Low-level API
|
|
43
|
+
|
|
44
|
+
`createGoogleAuthMiniApp` and `createPasswordLoginBlockPlugin` stay exported for hosts that need to control the mount point themselves. Both edits are required; the wrapper exists so they cannot drift apart.
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import {
|
|
48
|
+
createGoogleAuthMiniApp,
|
|
49
|
+
createPasswordLoginBlockPlugin,
|
|
50
|
+
} from '@mirrormedia/lilith-google-auth'
|
|
51
|
+
import type { KeystoneContext } from '@mirrormedia/lilith-google-auth'
|
|
52
|
+
|
|
53
|
+
// inside config.server.extendExpressApp(app, context):
|
|
54
|
+
if (envVar.googleAuth.isEnabled) {
|
|
55
|
+
app.use(
|
|
56
|
+
createGoogleAuthMiniApp({
|
|
57
|
+
// Keystone's generated context is structurally compatible with the
|
|
58
|
+
// narrow interface this package declares.
|
|
59
|
+
keystoneContext: context as unknown as KeystoneContext,
|
|
60
|
+
clientId: envVar.googleAuth.clientId,
|
|
61
|
+
clientSecret: envVar.googleAuth.clientSecret,
|
|
62
|
+
callbackUrl: envVar.googleAuth.callbackUrl,
|
|
63
|
+
allowedDomains: envVar.googleAuth.allowedDomains,
|
|
64
|
+
passwordLoginEnabled: envVar.googleAuth.passwordLoginEnabled,
|
|
65
|
+
stateSecret: envVar.session.secret,
|
|
66
|
+
})
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Mounting it after the host's own `X-Robots-Tag` middleware is harmless: the mini-app overwrites the header with the same value.
|
|
72
|
+
|
|
73
|
+
## Options
|
|
74
|
+
|
|
75
|
+
| Option | Required | Default | Meaning |
|
|
76
|
+
| ----------------------- | -------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
77
|
+
| `keystoneContext` | yes | | Keystone's context, cast to the package's `KeystoneContext`. Supplied by `withGoogleAuth`. |
|
|
78
|
+
| `clientId` | yes | | OAuth 2.0 Web client ID. |
|
|
79
|
+
| `clientSecret` | yes | | OAuth 2.0 client secret. |
|
|
80
|
+
| `callbackUrl` | yes | | Absolute http(s) URL of the callback. Its path becomes the callback route and the state cookie's `Path`; `https` also makes the state cookie `Secure`. |
|
|
81
|
+
| `allowedDomains` | yes | | Google `hd` allow-list. At least one entry. |
|
|
82
|
+
| `stateSecret` | yes | | HMAC key for the short-lived state cookie. Reuse `SESSION_SECRET`; at least 32 characters. |
|
|
83
|
+
| `passwordLoginEnabled` | no | `true` | `false` hides the password page and turns on the kill switch. |
|
|
84
|
+
| `graphqlPath` | no | `/api/graphql` | Where the HTTP guard is mounted. Must equal the host's `config.graphql.path`. |
|
|
85
|
+
| `signinRedirectDefault` | no | `/` | Where to send the user after login when no `from` is present. |
|
|
86
|
+
| `logger` | no | single-line JSON to stdout/stderr | Receives a `GoogleAuthLogEvent`. A throwing logger never blocks the redirect. See [Log events](#log-events). |
|
|
87
|
+
| `isEnabled` | no | `true` | `withGoogleAuth` only. `false` returns the host config untouched. |
|
|
88
|
+
|
|
89
|
+
## Password-login kill switch: two layers
|
|
90
|
+
|
|
91
|
+
Turning `passwordLoginEnabled` off needs **both** layers. Registering only one leaves the mutation reachable.
|
|
92
|
+
|
|
93
|
+
1. **HTTP guard** (automatic). The mini-app mounts an Express guard on `graphqlPath` that answers 403 to any JSON body selecting `authenticateUserWithPassword`.
|
|
94
|
+
2. **Apollo plugin** (automatic with `withGoogleAuth`; a low-level host must add it). Keystone core mounts `graphqlUploadExpress` _after_ `extendExpressApp`, so a multipart request reaches the guard with `req.body` still `{}` and passes; `graphql-upload` then fills `req.body` from the `operations` field and Apollo executes the mutation. `createPasswordLoginBlockPlugin()` inspects the parsed `DocumentNode` inside Apollo, after that rewrite, and is therefore immune:
|
|
95
|
+
|
|
96
|
+
`withGoogleAuth` registers this plugin for you. A low-level host does it by hand:
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
import { createPasswordLoginBlockPlugin } from '@mirrormedia/lilith-google-auth'
|
|
100
|
+
|
|
101
|
+
const apolloPlugins = [
|
|
102
|
+
...(envVar.googleAuth.isEnabled && !envVar.googleAuth.passwordLoginEnabled
|
|
103
|
+
? [createPasswordLoginBlockPlugin()]
|
|
104
|
+
: []),
|
|
105
|
+
// ...any cache plugins the package already registers
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
const graphqlConfig: GraphQLConfig = {
|
|
109
|
+
apolloConfig:
|
|
110
|
+
apolloPlugins.length > 0 ? { plugins: apolloPlugins } : undefined,
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The plugin throws a `GraphQLError` with `extensions.code = 'PASSWORD_LOGIN_DISABLED'` and `extensions.http.status = 403`. It walks every `mutation` operation, including fields reached through a top-level fragment spread, so aliases and fragments cannot hide the field; a query that merely names it inside a string literal is not blocked.
|
|
115
|
+
|
|
116
|
+
`graphql` is a peer dependency (`^16`) because the plugin works on the host's own `DocumentNode`.
|
|
117
|
+
|
|
118
|
+
### Verifying the toggle
|
|
119
|
+
|
|
120
|
+
With `GOOGLE_AUTH_PASSWORD_LOGIN_ENABLED=false`, all three must return 403:
|
|
121
|
+
|
|
122
|
+
```sh
|
|
123
|
+
# 1. plain JSON: stopped by the HTTP guard
|
|
124
|
+
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:3003/api/graphql \
|
|
125
|
+
-H 'content-type: application/json' \
|
|
126
|
+
-d '{"query":"mutation { authenticateUserWithPassword(email:\"a\",password:\"b\") { __typename } }"}'
|
|
127
|
+
|
|
128
|
+
# 2. multipart: slips past the HTTP guard, stopped by the Apollo plugin.
|
|
129
|
+
# The apollo-require-preflight header is what a real multipart client sends;
|
|
130
|
+
# without it Apollo's CSRF prevention answers 400 before any plugin runs, so
|
|
131
|
+
# a 400 here proves nothing about the kill switch.
|
|
132
|
+
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:3003/api/graphql \
|
|
133
|
+
-H 'apollo-require-preflight: true' \
|
|
134
|
+
-F 'operations={"query":"mutation { authenticateUserWithPassword(email:\"a\",password:\"b\") { __typename } }","variables":{}}' \
|
|
135
|
+
-F 'map={}'
|
|
136
|
+
|
|
137
|
+
# 3. aliased mutation
|
|
138
|
+
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:3003/api/graphql \
|
|
139
|
+
-H 'content-type: application/json' \
|
|
140
|
+
-d '{"query":"mutation { login: authenticateUserWithPassword(email:\"a\",password:\"b\") { __typename } }"}'
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
If step 2 returns 200, the Apollo plugin is not registered.
|
|
144
|
+
|
|
145
|
+
## Environment variables (consumer side)
|
|
146
|
+
|
|
147
|
+
| Variable | Meaning |
|
|
148
|
+
| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------- |
|
|
149
|
+
| `GOOGLE_AUTH_CLIENT_ID` | OAuth 2.0 Web client ID. Unset (or blank) means the mini-app is not mounted. |
|
|
150
|
+
| `GOOGLE_AUTH_CLIENT_SECRET` | Client secret, from Secret Manager. |
|
|
151
|
+
| `GOOGLE_AUTH_CALLBACK_URL` | Absolute URL of `/auth/google/callback` for this deployment. |
|
|
152
|
+
| `GOOGLE_AUTH_ALLOWED_DOMAINS` | Required. Comma-separated Workspace domains, e.g. `mirrormedia.mg,readr.tw`. An empty list makes construction throw. |
|
|
153
|
+
| `GOOGLE_AUTH_PASSWORD_LOGIN_ENABLED` | `false` disables password login. Default `true`. |
|
|
154
|
+
|
|
155
|
+
`stateSecret` has no variable of its own: consumers pass `SESSION_SECRET` (already required to be at least 32 characters).
|
|
156
|
+
|
|
157
|
+
## User matching
|
|
158
|
+
|
|
159
|
+
The callback looks the `User` row up by the Google account's email, lower-cased. `User.email` must therefore be stored lower-case, otherwise the lookup returns `no_user` for an account that does exist.
|
|
160
|
+
|
|
161
|
+
## Log events
|
|
162
|
+
|
|
163
|
+
The `GoogleAuthLogEvent` field names match `@mirrormedia/lilith-core`'s login-logging plugin, so one log query covers password and Google logins:
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
type GoogleAuthLogEvent = {
|
|
167
|
+
type: 'google-login'
|
|
168
|
+
outcome: 'success' | 'failure'
|
|
169
|
+
reason?: GoogleAuthErrorCode
|
|
170
|
+
timestamp: string // ISO 8601
|
|
171
|
+
userId: string | null
|
|
172
|
+
email: string | null
|
|
173
|
+
name: string | null
|
|
174
|
+
role: string | null
|
|
175
|
+
ipAddress: string | null // x-forwarded-for[0], else x-real-ip, else socket
|
|
176
|
+
userAgent: string | null
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
`::1` and `::ffff:127.0.0.1` are normalised to `127.0.0.1`.
|
|
181
|
+
|
|
182
|
+
### Structured JSON output
|
|
183
|
+
|
|
184
|
+
The default logger, and the two places an unexpected error is logged (the callback's catch-all, and a caller-supplied `logger` that throws), all print a single-line JSON object with a `severity` field, so Cloud Logging parses `jsonPayload` instead of a multi-line `textPayload`, and an `ERROR` entry (whose `message` is the stack trace) is picked up by Error Reporting. No dependency on `@twreporter/errors`; the shape is a hand-rolled envelope shared with the equivalent change in `@mirrormedia/lilith-core`.
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
type LogEntry = {
|
|
188
|
+
severity: 'INFO' | 'WARNING' | 'ERROR'
|
|
189
|
+
message: string
|
|
190
|
+
} & Record<string, unknown>
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Severity mapping:
|
|
194
|
+
|
|
195
|
+
- `outcome: 'success'` → `severity: 'INFO'`, `message: 'google-login success'`, every `GoogleAuthLogEvent` field top-level (no nesting).
|
|
196
|
+
- `outcome: 'failure'` → `severity: 'WARNING'`, `message: 'google-login failure: <reason>'`, every `GoogleAuthLogEvent` field top-level.
|
|
197
|
+
- An unexpected throw (callback catch-all, or a `logger` that throws) → `severity: 'ERROR'`, `message` is the `Error`'s stack trace (or `String(err)` for a non-`Error`), plus `type: 'google-login'`, `stage: 'callback' | 'logger'`, `email`, `timestamp`.
|
|
198
|
+
|
|
199
|
+
`formatLogEntry` and the `LogEntry` type are exported for a caller that wants the same envelope from a custom `logger`:
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
import { formatLogEntry } from '@mirrormedia/lilith-google-auth'
|
|
203
|
+
import type { LogEntry } from '@mirrormedia/lilith-google-auth'
|
|
204
|
+
|
|
205
|
+
const logger = (event: GoogleAuthLogEvent) => {
|
|
206
|
+
const entry: LogEntry = formatLogEntry(event)
|
|
207
|
+
const line = JSON.stringify(entry)
|
|
208
|
+
entry.severity === 'ERROR' ? console.error(line) : console.log(line)
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Example Logs Explorer query, scoped to failed and errored Google logins in a given service:
|
|
213
|
+
|
|
214
|
+
```
|
|
215
|
+
resource.type="cloud_run_revision"
|
|
216
|
+
jsonPayload.type="google-login"
|
|
217
|
+
severity>=WARNING
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Add `jsonPayload.stage="callback"` to isolate unexpected errors raised during the OAuth callback, or `severity="ERROR"` to see only entries Error Reporting also tracks.
|
|
221
|
+
|
|
222
|
+
## Google Cloud setup
|
|
223
|
+
|
|
224
|
+
1. In the GCP project that owns the Workspace domain, create an OAuth 2.0 Web client.
|
|
225
|
+
2. Register every environment's callback URL (dev, staging, prod, plus `http://localhost:<port>/auth/google/callback` for local dev).
|
|
226
|
+
3. Set the consent screen to Internal so only Workspace accounts can complete the flow. The `hd` check in this package is the second layer.
|
|
227
|
+
|
|
228
|
+
## Error codes
|
|
229
|
+
|
|
230
|
+
`/signin?error=<code>` where code is one of `state`, `token`, `domain`, `unverified_email`, `no_user`, `session`. Messages live in `ERROR_MESSAGES`.
|
|
231
|
+
|
|
232
|
+
## Build and test
|
|
233
|
+
|
|
234
|
+
```
|
|
235
|
+
yarn test # tsx + node:test
|
|
236
|
+
make build # babel -> lib/, tsc -> @types/
|
|
237
|
+
```
|
package/lib/google.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.createGoogleClient = createGoogleClient;
|
|
7
|
+
|
|
8
|
+
var _googleAuthLibrary = require("google-auth-library");
|
|
9
|
+
|
|
10
|
+
function createGoogleClient(config) {
|
|
11
|
+
const oauth2 = new _googleAuthLibrary.OAuth2Client({
|
|
12
|
+
clientId: config.clientId,
|
|
13
|
+
clientSecret: config.clientSecret,
|
|
14
|
+
redirectUri: config.callbackUrl
|
|
15
|
+
});
|
|
16
|
+
return {
|
|
17
|
+
buildAuthUrl({
|
|
18
|
+
state,
|
|
19
|
+
nonce,
|
|
20
|
+
hdHint
|
|
21
|
+
}) {
|
|
22
|
+
const url = new URL(oauth2.generateAuthUrl({
|
|
23
|
+
scope: ['openid', 'email', 'profile'],
|
|
24
|
+
state,
|
|
25
|
+
prompt: 'select_account',
|
|
26
|
+
...(hdHint ? {
|
|
27
|
+
hd: hdHint
|
|
28
|
+
} : {})
|
|
29
|
+
})); // generateAuthUrl has no nonce option; OIDC nonce is added by hand.
|
|
30
|
+
|
|
31
|
+
url.searchParams.set('nonce', nonce);
|
|
32
|
+
return url.toString();
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
async exchangeCode(code) {
|
|
36
|
+
const {
|
|
37
|
+
tokens
|
|
38
|
+
} = await oauth2.getToken(code);
|
|
39
|
+
|
|
40
|
+
if (!tokens.id_token) {
|
|
41
|
+
throw new Error('Google token response did not include an id_token');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const ticket = await oauth2.verifyIdToken({
|
|
45
|
+
idToken: tokens.id_token,
|
|
46
|
+
audience: config.clientId
|
|
47
|
+
});
|
|
48
|
+
const payload = ticket.getPayload();
|
|
49
|
+
if (!payload) throw new Error('Google ID token has no payload');
|
|
50
|
+
return {
|
|
51
|
+
email: payload.email ? payload.email.toLowerCase() : null,
|
|
52
|
+
emailVerified: payload.email_verified === true,
|
|
53
|
+
hd: payload.hd ?? null,
|
|
54
|
+
nonce: payload.nonce ?? null
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
};
|
|
59
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
Object.defineProperty(exports, "ERROR_MESSAGES", {
|
|
7
|
+
enumerable: true,
|
|
8
|
+
get: function () {
|
|
9
|
+
return _signinPage.ERROR_MESSAGES;
|
|
10
|
+
}
|
|
11
|
+
});
|
|
12
|
+
Object.defineProperty(exports, "PASSWORD_MUTATION_FIELD", {
|
|
13
|
+
enumerable: true,
|
|
14
|
+
get: function () {
|
|
15
|
+
return _passwordPlugin.PASSWORD_MUTATION_FIELD;
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
Object.defineProperty(exports, "createGoogleAuthMiniApp", {
|
|
19
|
+
enumerable: true,
|
|
20
|
+
get: function () {
|
|
21
|
+
return _miniApp.createGoogleAuthMiniApp;
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
Object.defineProperty(exports, "createPasswordLoginBlockPlugin", {
|
|
25
|
+
enumerable: true,
|
|
26
|
+
get: function () {
|
|
27
|
+
return _passwordPlugin.createPasswordLoginBlockPlugin;
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
Object.defineProperty(exports, "documentSelectsPasswordLogin", {
|
|
31
|
+
enumerable: true,
|
|
32
|
+
get: function () {
|
|
33
|
+
return _passwordPlugin.documentSelectsPasswordLogin;
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
Object.defineProperty(exports, "formatLogEntry", {
|
|
37
|
+
enumerable: true,
|
|
38
|
+
get: function () {
|
|
39
|
+
return _log.formatLogEntry;
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
Object.defineProperty(exports, "withGoogleAuth", {
|
|
43
|
+
enumerable: true,
|
|
44
|
+
get: function () {
|
|
45
|
+
return _withGoogleAuth.withGoogleAuth;
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
var _miniApp = require("./mini-app");
|
|
50
|
+
|
|
51
|
+
var _log = require("./log");
|
|
52
|
+
|
|
53
|
+
var _passwordPlugin = require("./password-plugin");
|
|
54
|
+
|
|
55
|
+
var _signinPage = require("./signin-page");
|
|
56
|
+
|
|
57
|
+
var _withGoogleAuth = require("./with-google-auth");
|