kempo-oauth 0.0.0-stage → 0.1.1

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.
Files changed (48) hide show
  1. package/README.md +71 -2
  2. package/admin/components/LinkedAccounts.js +134 -0
  3. package/admin/components/ProviderSettings.js +257 -0
  4. package/admin/index.page.html +66 -0
  5. package/admin/nav.global.html +3 -0
  6. package/install.js +48 -0
  7. package/kempo-config.json +154 -0
  8. package/package.json +53 -4
  9. package/public/api/accounts/GET.js +18 -0
  10. package/public/api/accounts/[id]/DELETE.js +17 -0
  11. package/public/api/callback/[provider]/GET.js +62 -0
  12. package/public/api/providers/GET.js +12 -0
  13. package/public/api/settings/GET.js +12 -0
  14. package/public/api/settings/PUT.js +148 -0
  15. package/public/api/start/[provider]/GET.js +43 -0
  16. package/public/components/OauthButtons.js +111 -0
  17. package/public/icons/facebook.svg +1 -0
  18. package/public/icons/github.svg +1 -0
  19. package/public/icons/google.svg +1 -0
  20. package/public/icons/microsoft.svg +1 -0
  21. package/public/icons/x.svg +1 -0
  22. package/public/login-buttons.global.html +4 -0
  23. package/public/register-buttons.global.html +4 -0
  24. package/public/sdk.js +59 -0
  25. package/sdk.js +21 -0
  26. package/server/db/schema.js +31 -0
  27. package/server/http/cookies.js +42 -0
  28. package/server/http/gate.js +31 -0
  29. package/server/http/origin.js +21 -0
  30. package/server/http/redirects.js +12 -0
  31. package/server/providers/facebook.js +43 -0
  32. package/server/providers/github.js +52 -0
  33. package/server/providers/google.js +35 -0
  34. package/server/providers/index.js +76 -0
  35. package/server/providers/microsoft.js +47 -0
  36. package/server/providers/x.js +43 -0
  37. package/server/utils/accounts/signIn.js +163 -0
  38. package/server/utils/accounts/store.js +89 -0
  39. package/server/utils/config/declared.js +24 -0
  40. package/server/utils/config/describe.js +38 -0
  41. package/server/utils/config/settings.js +83 -0
  42. package/server/utils/flow/exchange.js +70 -0
  43. package/server/utils/flow/http.js +47 -0
  44. package/server/utils/flow/pkce.js +6 -0
  45. package/server/utils/flow/safePath.js +20 -0
  46. package/server/utils/flow/state.js +44 -0
  47. package/uninstall.js +22 -0
  48. package/update.js +13 -0
package/README.md CHANGED
@@ -1,3 +1,72 @@
1
- # Temporary Holding Version
1
+ # kempo-oauth
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Sign in and register with Google, GitHub, Facebook, X and Microsoft on a [kempo](https://github.com/dustinpoissant/kempo) site. The client ids and secrets are managed in the admin portal; the buttons appear under the login and register forms.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install kempo-oauth
9
+ ```
10
+
11
+ Then install it from the admin portal (Extensions). Client secrets are stored as kempo `secret` settings, so `SETTINGS_ENCRYPTION_KEY` must be set in the site's environment first — generate one with `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"` and back it up.
12
+
13
+ ### Sites created before the slots existed
14
+
15
+ The buttons are pushed into two slots on kempo's login and register pages: `login-form-after` and `register-form-after`. Pages copied from a kempo older than that don't have them. Add one line just after the closing `</form>` of each:
16
+
17
+ ```html
18
+ <!-- public/login/index.page.html -->
19
+ <location name="login-form-after" />
20
+
21
+ <!-- public/register/index.page.html -->
22
+ <location name="register-form-after" />
23
+ ```
24
+
25
+ ## Connecting a provider
26
+
27
+ Open **Sign-in Providers** in the admin portal. Each provider card shows the **redirect URI** to give the provider (`https://your-site/oauth/api/callback/<provider>`), a link to the provider's developer console, and fields for the client id and secret. A provider is shown on the login page only when it is switched on **and** has both.
28
+
29
+ Behind a proxy that changes the scheme or host, set **Site address** so the redirect URI is built from the public address rather than the one this process sees.
30
+
31
+ | Provider | Gives an email? | Email trusted to join an existing account? |
32
+ |---|---|---|
33
+ | Google | yes | yes, when Google says it is verified |
34
+ | GitHub | yes (primary verified) | yes |
35
+ | Facebook | yes | **no** — Facebook never says it was confirmed |
36
+ | Microsoft | yes | **no** — a directory can hold any address |
37
+ | X | **no** | — |
38
+
39
+ ## How accounts are matched
40
+
41
+ - A returning person is found by the provider's **id**, never the email.
42
+ - A first-time sign-in whose email matches an existing account is connected to it **only if the provider vouches for the email** (and "Connect … with the same email" is on). Otherwise the visitor is told an account exists and to sign in the way they signed up.
43
+ - If that existing account never verified its email, its password is invalidated and its sessions ended before connecting (guards against someone pre-registering your address).
44
+ - Otherwise a new account is created, honouring `allow_registration` and `require_email_verification`. Its password is random and unknown; the person can set one with "forgot password".
45
+ - X shares no email, so its accounts get an `…@oauth.invalid` placeholder. A site requiring verified emails cannot offer X.
46
+
47
+ ## Security notes
48
+
49
+ Authorization code flow with PKCE (S256) and a per-attempt `state`, held in a short-lived HttpOnly `lax` cookie scoped to `/oauth/api`. Nothing is stored server-side mid-flow, and provider access tokens are discarded after reading the profile. Redirect targets (`next`, "after sign-in") must be paths on this site. Failures return to the login page as a short code, never text from the provider.
50
+
51
+ ## Permissions
52
+
53
+ `oauth:view` (see connected accounts), `oauth:unlink` (disconnect one), `oauth:settings` (providers and secrets). Groups: `kempo-oauth:viewer`, `kempo-oauth:administrator`.
54
+
55
+ ## Adding a provider
56
+
57
+ One file in `server/providers/` (contract documented at the top of `server/providers/index.js`) plus its settings in `kempo-config.json`; a test fails until both exist. Another kempo (CMS) extension can call `registerProvider` from `kempo-oauth/sdk`.
58
+
59
+ ## Out of scope
60
+
61
+ Apple (needs a signed-JWT client secret), Discord, LinkedIn and others (each is one provider file), OpenID discovery, refresh tokens / calling providers on a user's behalf, and a "connect another provider" button on the account page.
62
+
63
+ ## Development
64
+
65
+ ```bash
66
+ docker compose up -d # test database on port 5445
67
+ npm run link:local
68
+ DATABASE_URL=postgresql://kempo:kempo@localhost:5445/kempo_oauth_test npx drizzle-kit push --force
69
+ DATABASE_URL=postgresql://kempo:kempo@localhost:5445/kempo_oauth_test SETTINGS_ENCRYPTION_KEY=$(openssl rand -hex 32) npm test
70
+ ```
71
+
72
+ Check for `(SKIPPED)` in the output: database suites skip themselves, and a skip reads as a pass. They also refuse any database not named `*_test`.
@@ -0,0 +1,134 @@
1
+ import ShadowComponent from '/kempo-ui/components/ShadowComponent.js';
2
+ import '/kempo-ui/components/Icon.js';
3
+ import '/kempo-ui/components/Spinner.js';
4
+ import { html } from '/kempo-ui/lit-all.min.js';
5
+ import { deleteAccount, getAccounts } from '/oauth/sdk.js';
6
+
7
+ /*
8
+ Who has signed in with which provider.
9
+
10
+ A row whose user no longer exists is shown as such rather than hidden: it is harmless (the next
11
+ sign-in with that provider replaces it), but an admin looking at "Unknown user" wants to be told
12
+ it is a deleted account and not a bug.
13
+ */
14
+
15
+ const PAGE_SIZE = 25;
16
+
17
+ export default class LinkedAccounts extends ShadowComponent {
18
+ static properties = {
19
+ result: { type: Object, state: true },
20
+ providers: { type: Array, state: true },
21
+ provider: { type: String, state: true },
22
+ offset: { type: Number, state: true },
23
+ error: { type: String, state: true },
24
+ };
25
+
26
+ constructor(){
27
+ super();
28
+ this.result = null;
29
+ this.providers = [];
30
+ this.provider = '';
31
+ this.offset = 0;
32
+ this.error = '';
33
+ }
34
+
35
+ /*
36
+ Lifecycle
37
+ */
38
+
39
+ connectedCallback(){
40
+ super.connectedCallback();
41
+ this.load();
42
+ }
43
+
44
+ /*
45
+ Data
46
+ */
47
+
48
+ load = async () => {
49
+ const [error, result] = await getAccounts({ limit: PAGE_SIZE, offset: this.offset, provider: this.provider });
50
+ if(error){
51
+ this.error = error.msg;
52
+ return;
53
+ }
54
+
55
+ this.error = '';
56
+ this.result = result;
57
+ this.providers = result.providers;
58
+ };
59
+
60
+ filter = event => {
61
+ this.provider = event.target.value;
62
+ this.offset = 0;
63
+ this.load();
64
+ };
65
+
66
+ page = step => {
67
+ this.offset = Math.max(this.offset + step * PAGE_SIZE, 0);
68
+ this.load();
69
+ };
70
+
71
+ disconnect = async account => {
72
+ if(!confirm(`Disconnect ${account.provider} from ${account.userName || 'this user'}? They keep their account.`)) return;
73
+
74
+ const [error] = await deleteAccount(account.id);
75
+ if(error){
76
+ this.error = error.msg;
77
+ return;
78
+ }
79
+
80
+ this.load();
81
+ };
82
+
83
+ /*
84
+ Rendering
85
+ */
86
+
87
+ render(){
88
+ if(!this.result) return this.error ? html`<p class="tc-danger">${this.error}</p>` : html`<k-spinner></k-spinner>`;
89
+
90
+ const { accounts, total, offset } = this.result;
91
+
92
+ return html`
93
+ ${this.error ? html`<p class="tc-danger">${this.error}</p>` : ''}
94
+
95
+ ${this.providers.length ? html`
96
+ <label>
97
+ <span class="d-b">Provider</span>
98
+ <select @change=${this.filter}>
99
+ <option value="" ?selected=${!this.provider}>All</option>
100
+ ${this.providers.map(provider => html`<option value=${provider.name} ?selected=${provider.name === this.provider}>${provider.label}</option>`)}
101
+ </select>
102
+ </label>
103
+ ` : ''}
104
+
105
+ ${accounts.length ? html`
106
+ <div class="table-wrapper">
107
+ <table>
108
+ <thead>
109
+ <tr><th>Provider</th><th>User</th><th>Email from provider</th><th>Last sign-in</th><th></th></tr>
110
+ </thead>
111
+ <tbody>
112
+ ${accounts.map(account => html`
113
+ <tr>
114
+ <td>${account.provider}</td>
115
+ <td>${account.userExists ? html`${account.userName}<br /><span class="small tc-muted">${account.userEmail}</span>` : html`<span class="tc-muted">Deleted user</span>`}</td>
116
+ <td>${account.email || html`<span class="tc-muted">none shared</span>`}</td>
117
+ <td>${new Date(account.lastLoginAt).toLocaleString()}</td>
118
+ <td><button class="link tc-danger" title="Disconnect" @click=${() => this.disconnect(account)}><k-icon name="delete"></k-icon></button></td>
119
+ </tr>
120
+ `)}
121
+ </tbody>
122
+ </table>
123
+ </div>
124
+ <p class="small tc-muted">
125
+ ${offset + 1}–${offset + accounts.length} of ${total}
126
+ <button class="link" ?disabled=${offset === 0} @click=${() => this.page(-1)}>Previous</button>
127
+ <button class="link" ?disabled=${offset + accounts.length >= total} @click=${() => this.page(1)}>Next</button>
128
+ </p>
129
+ ` : html`<p class="tc-muted">Nobody has signed in with a provider yet.</p>`}
130
+ `;
131
+ }
132
+ }
133
+
134
+ customElements.define('k-oauth-accounts', LinkedAccounts);
@@ -0,0 +1,257 @@
1
+ import ShadowComponent from '/kempo-ui/components/ShadowComponent.js';
2
+ import '/kempo-ui/components/Icon.js';
3
+ import '/kempo-ui/components/Spinner.js';
4
+ import { html } from '/kempo-ui/lit-all.min.js';
5
+ import { getSettings, saveSettings } from '/oauth/sdk.js';
6
+
7
+ /*
8
+ Connecting the site to sign-in providers.
9
+
10
+ Three things on this screen are load-bearing and none of them is the form itself:
11
+
12
+ - **The redirect URI.** Copy-pasteable, one per provider, beside the fields it goes with. A site
13
+ whose redirect URI is wrong gets as far as the provider's own error page, which names neither
14
+ this site nor the cause, and the person looking at it is not the person who can fix it.
15
+ - **The status line.** "Active", "Off" and "Incomplete" are different things and the third is the
16
+ one that surprises people: a provider with an id and no secret is simply not offered, and
17
+ nothing on the login page says why.
18
+ - **Blank means unchanged.** A secret cannot be read back, so its field is always empty. Saving
19
+ the form after changing something else must not wipe the secret, and clearing one on purpose is
20
+ a separate, explicit button.
21
+ */
22
+ export default class ProviderSettings extends ShadowComponent {
23
+ static properties = {
24
+ data: { type: Object, state: true },
25
+ edits: { type: Object, state: true },
26
+ saving: { type: Boolean, state: true },
27
+ error: { type: String, state: true },
28
+ notice: { type: String, state: true },
29
+ };
30
+
31
+ constructor(){
32
+ super();
33
+ this.data = null;
34
+ /*
35
+ Only what was touched, shaped like the request body. An untouched field is not sent at all,
36
+ which is what makes "blank means unchanged" true on the wire and not only in the route.
37
+ */
38
+ this.edits = { global: {}, providers: {} };
39
+ this.saving = false;
40
+ this.error = '';
41
+ this.notice = '';
42
+ }
43
+
44
+ /*
45
+ Lifecycle
46
+ */
47
+
48
+ connectedCallback(){
49
+ super.connectedCallback();
50
+ this.load();
51
+ }
52
+
53
+ /*
54
+ Data
55
+ */
56
+
57
+ load = async () => {
58
+ const [error, data] = await getSettings();
59
+ if(error){
60
+ this.error = error.msg;
61
+ return;
62
+ }
63
+
64
+ this.data = data;
65
+ this.edits = { global: {}, providers: {} };
66
+ };
67
+
68
+ editGlobal = (name, value) => {
69
+ this.edits = { ...this.edits, global: { ...this.edits.global, [name]: value } };
70
+ };
71
+
72
+ editProvider = (provider, name, value) => {
73
+ const current = this.edits.providers[provider] || {};
74
+ this.edits = { ...this.edits, providers: { ...this.edits.providers, [provider]: { ...current, [name]: value } } };
75
+ };
76
+
77
+ save = async () => {
78
+ this.saving = true;
79
+ this.error = '';
80
+ this.notice = '';
81
+
82
+ const [error] = await saveSettings(this.edits);
83
+ this.saving = false;
84
+
85
+ if(error){
86
+ this.error = error.msg;
87
+ return;
88
+ }
89
+
90
+ this.notice = 'Saved.';
91
+ await this.load();
92
+ };
93
+
94
+ clearSecret = async name => {
95
+ this.edits = { ...this.edits, providers: { ...this.edits.providers, [name]: { client_secret: null } } };
96
+ await this.save();
97
+ };
98
+
99
+ copy = (text) => {
100
+ navigator.clipboard?.writeText(text);
101
+ this.notice = 'Copied.';
102
+ };
103
+
104
+ get dirty(){
105
+ return Object.keys(this.edits.global).length > 0 || Object.keys(this.edits.providers).length > 0;
106
+ }
107
+
108
+ /*
109
+ Rendering
110
+ */
111
+
112
+ #valueOf = (provider, name, stored) => this.edits.providers[provider.name]?.[name] ?? stored;
113
+
114
+ #renderStatus(provider){
115
+ if(provider.active) return html`<span class="tc-success">Active — shown on the login and register pages</span>`;
116
+ if(provider.enabled && !provider.configured) return html`<span class="tc-warning">Incomplete — needs both a client id and a client secret before it is shown</span>`;
117
+ return html`<span class="tc-muted">Off</span>`;
118
+ }
119
+
120
+ #renderProvider(provider){
121
+ const secretEdit = this.edits.providers[provider.name]?.client_secret;
122
+
123
+ return html`
124
+ <div class="card mb">
125
+ <h3>
126
+ ${provider.mono
127
+ ? html`<k-icon class="mrh" src="/oauth/icons/${provider.icon}"></k-icon>`
128
+ : html`<img class="icon mrh" src="/oauth/icons/${provider.icon}" alt="" />`}${provider.label}
129
+ </h3>
130
+ <p class="small">${this.#renderStatus(provider)}</p>
131
+
132
+ <label class="checkbox">
133
+ <input
134
+ type="checkbox"
135
+ .checked=${this.#valueOf(provider, 'enabled', provider.enabled)}
136
+ @change=${event => this.editProvider(provider.name, 'enabled', event.target.checked)}
137
+ />
138
+ Offer “Continue with ${provider.label}”
139
+ </label>
140
+
141
+ <p class="small tc-muted mt">
142
+ Create an OAuth app in the <a href=${provider.consoleUrl} target="_blank" rel="noopener">${provider.label} developer console</a>
143
+ and give it this redirect URI:
144
+ </p>
145
+ <div class="d-f mbh">
146
+ <input class="ff-mono flex" type="text" readonly .value=${provider.redirectUri} />
147
+ <button class="secondary" @click=${() => this.copy(provider.redirectUri)}><k-icon name="content_copy"></k-icon> Copy</button>
148
+ </div>
149
+
150
+ <label>
151
+ <span class="d-b">Client id</span>
152
+ <input
153
+ type="text"
154
+ autocomplete="off"
155
+ .value=${this.#valueOf(provider, 'client_id', provider.clientId)}
156
+ @input=${event => this.editProvider(provider.name, 'client_id', event.target.value)}
157
+ />
158
+ </label>
159
+
160
+ <label>
161
+ <span class="d-b">Client secret ${provider.hasSecret ? html`<span class="small tc-success">· set</span>` : ''}</span>
162
+ <input
163
+ type="password"
164
+ autocomplete="off"
165
+ placeholder=${provider.hasSecret ? '•••••••• — leave blank to keep' : ''}
166
+ .value=${typeof secretEdit === 'string' ? secretEdit : ''}
167
+ @input=${event => this.editProvider(provider.name, 'client_secret', event.target.value)}
168
+ />
169
+ ${provider.hasSecret ? html`
170
+ <button class="link small" ?disabled=${this.saving} @click=${() => this.clearSecret(provider.name)}>Clear this secret</button>
171
+ ` : ''}
172
+ </label>
173
+
174
+ ${provider.extraFields.map(field => html`
175
+ <label>
176
+ <span class="d-b">${field.label}</span>
177
+ <span class="small tc-muted d-b">${field.help}</span>
178
+ <input
179
+ type="text"
180
+ autocomplete="off"
181
+ .value=${this.#valueOf(provider, field.name, field.value)}
182
+ @input=${event => this.editProvider(provider.name, field.name, event.target.value)}
183
+ />
184
+ </label>
185
+ `)}
186
+ </div>
187
+ `;
188
+ }
189
+
190
+ #renderGlobal(){
191
+ const { global } = this.data;
192
+ const value = (name, key) => this.edits.global[name] ?? global[key];
193
+
194
+ return html`
195
+ <h2>Site</h2>
196
+
197
+ <label>
198
+ <span class="d-b">Site address</span>
199
+ <span class="small tc-muted d-b">
200
+ Where this site is reached, such as https://example.com. Leave empty to work it out from each request —
201
+ fine for a site reached directly, wrong behind a proxy that changes the scheme or the host.
202
+ Every redirect URI below is built from it.
203
+ </span>
204
+ <input type="text" placeholder="https://example.com" .value=${value('base_url', 'baseUrl')} @input=${event => this.editGlobal('base_url', event.target.value)} />
205
+ </label>
206
+
207
+ <label>
208
+ <span class="d-b">After signing in</span>
209
+ <span class="small tc-muted d-b">The page a visitor lands on, unless the page they came from asked for somewhere else.</span>
210
+ <input type="text" .value=${value('after_login_path', 'afterLoginPath')} @input=${event => this.editGlobal('after_login_path', event.target.value)} />
211
+ </label>
212
+
213
+ <label class="checkbox">
214
+ <input
215
+ type="checkbox"
216
+ .checked=${value('link_verified_emails', 'linkVerifiedEmails')}
217
+ @change=${event => this.editGlobal('link_verified_emails', event.target.checked)}
218
+ />
219
+ Connect a provider sign-in to an existing account with the same email, when the provider has confirmed that email
220
+ </label>
221
+ <p class="small tc-muted">
222
+ Turned off, someone who signed up with a password and later uses Google with the same address gets a second account.
223
+ A provider that does not confirm emails (Facebook, Microsoft) is never connected to an existing account either way.
224
+ </p>
225
+ `;
226
+ }
227
+
228
+ render(){
229
+ if(!this.data) return this.error ? html`<p class="tc-danger">${this.error}</p>` : html`<k-spinner></k-spinner>`;
230
+
231
+ return html`
232
+ ${!this.data.encryptionConfigured ? html`
233
+ <div class="card tc-danger mb">
234
+ <strong>SETTINGS_ENCRYPTION_KEY is not set on this server.</strong>
235
+ <p class="small">
236
+ Client secrets are encrypted with it, so none can be stored until it exists. Generate one with
237
+ <code class="ff-mono">node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"</code>,
238
+ put it in the site’s environment, and restart. Back it up — losing it makes every stored secret unrecoverable.
239
+ </p>
240
+ </div>
241
+ ` : ''}
242
+ ${this.error ? html`<p class="tc-danger">${this.error}</p>` : ''}
243
+ ${this.notice ? html`<p class="tc-success small">${this.notice}</p>` : ''}
244
+
245
+ <h2>Providers</h2>
246
+ ${this.data.providers.map(provider => this.#renderProvider(provider))}
247
+
248
+ ${this.#renderGlobal()}
249
+
250
+ <button class="primary mt" ?disabled=${this.saving || !this.dirty} @click=${this.save}>
251
+ ${this.saving ? 'Saving…' : 'Save settings'}
252
+ </button>
253
+ `;
254
+ }
255
+ }
256
+
257
+ customElements.define('k-oauth-settings', ProviderSettings);
@@ -0,0 +1,66 @@
1
+ <!--
2
+ locked: true
3
+ -->
4
+ <page title="Sign-in Providers - Admin">
5
+ <content>
6
+ <script type="module" src="/kempo/components/PermissionTarget.js"></script>
7
+ <script type="module" src="/kempo-ui/components/Tabs.js"></script>
8
+ <script type="module" src="/admin/extension/kempo-oauth/components/ProviderSettings.js"></script>
9
+ <script type="module" src="/admin/extension/kempo-oauth/components/LinkedAccounts.js"></script>
10
+
11
+ <div>
12
+ <h1>Sign-in Providers</h1>
13
+ <p class="tc-muted">
14
+ Let visitors sign in or register with an account they already have. Each provider needs an OAuth
15
+ app created in its own developer console; paste that app’s client id and secret here. Nothing
16
+ appears on the login and register pages until a provider is switched on and has both.
17
+ </p>
18
+
19
+ <k-tabs id="tabs" active="providers" persistent-id="kempo-oauth-admin-tab">
20
+ <k-tab id="providersTab" for="providers">Providers</k-tab>
21
+ <k-tab id="accountsTab" for="accounts">Connected accounts</k-tab>
22
+
23
+ <k-tab-content id="providersContent" name="providers">
24
+ <k-oauth-settings></k-oauth-settings>
25
+ </k-tab-content>
26
+
27
+ <k-tab-content id="accountsContent" name="accounts">
28
+ <k-oauth-accounts></k-oauth-accounts>
29
+ </k-tab-content>
30
+ </k-tabs>
31
+
32
+ <!--
33
+ k-tabs finds its tabs with a direct-child query, so nothing can wrap a <k-tab>/<k-tab-content>
34
+ pair — not even a hidden element. k-permission-target reaches in by id and hides the tab and
35
+ its pane in place instead. A courtesy only: the actual boundary is server-side, on each route.
36
+ -->
37
+ <k-permission-target has="oauth:settings" target="#providersTab, #providersContent"></k-permission-target>
38
+ <k-permission-target has="oauth:view" target="#accountsTab, #accountsContent"></k-permission-target>
39
+ </div>
40
+
41
+ <script type="module">
42
+ import { getAllCurrentUserPermissions } from '/kempo/sdk.js';
43
+
44
+ await customElements.whenDefined('k-tabs');
45
+
46
+ /*
47
+ persistent-id can restore whichever tab a returning visitor left open, including one this
48
+ visitor cannot see if their permissions changed since. The targets above hide a tab and its
49
+ pane; this makes sure a hidden one is never left selected.
50
+ */
51
+ const [error, data] = await getAllCurrentUserPermissions();
52
+ const permissions = new Set(error ? [] : (data?.permissions || []));
53
+
54
+ const tabs = document.getElementById('tabs');
55
+ if(tabs){
56
+ const visibility = {
57
+ providers: permissions.has('oauth:settings'),
58
+ accounts: permissions.has('oauth:view'),
59
+ };
60
+ if(!visibility[tabs.active]){
61
+ const fallback = Object.keys(visibility).find(name => visibility[name]);
62
+ if(fallback) tabs.active = fallback;
63
+ }
64
+ }
65
+ </script>
66
+ </content>
@@ -0,0 +1,3 @@
1
+ <content id="kempo-oauth-nav" name="Sign-in Providers" owner="kempo-oauth" enabled="true" locked="true" location="admin-nav-extensions" priority="4">
2
+ <k-aside-item icon="login" href="/admin/extension/kempo-oauth/" no-expand>Sign-in Providers</k-aside-item>
3
+ </content>
package/install.js ADDED
@@ -0,0 +1,48 @@
1
+ import { sql } from 'drizzle-orm';
2
+ import db from 'kempo/server/db/index.js';
3
+
4
+ /*
5
+ Two things the declarative config cannot do.
6
+
7
+ **The indexes.** kempo's installer generates `CREATE TABLE` from the Drizzle schema and reads
8
+ columns and primary keys off it — indexes and unique constraints declared on a table are not
9
+ carried across. Here one of them is a correctness guarantee: without a unique index on
10
+ `(provider, providerUserId)` two rows can claim the same Google account, and a sign-in then lands
11
+ on whichever user the database happens to return first. So it is created here, by hand, matching
12
+ what server/db/schema.js declares.
13
+
14
+ **The warning.** Every client secret this extension stores is a `secret` setting, and kempo can
15
+ only encrypt those when SETTINGS_ENCRYPTION_KEY is set. Without it the settings screen refuses to
16
+ save a secret, and the first sign of trouble is a form that fails with no obvious cause. Saying so
17
+ at install time is the difference between a two-minute fix and an hour.
18
+ */
19
+
20
+ const INDEXES = [
21
+ sql`CREATE UNIQUE INDEX IF NOT EXISTS "kempoOauthAccount_provider_idx" ON "kempoOauthAccount" ("provider", "providerUserId")`,
22
+ sql`CREATE INDEX IF NOT EXISTS "kempoOauthAccount_user_idx" ON "kempoOauthAccount" ("userId")`,
23
+ ];
24
+
25
+ export default async () => {
26
+ for(const statement of INDEXES){
27
+ try {
28
+ await db.execute(statement);
29
+ } catch(error) {
30
+ console.warn(`[kempo-oauth] Could not create an index: ${error.message}`);
31
+ }
32
+ }
33
+
34
+ if(!process.env.SETTINGS_ENCRYPTION_KEY){
35
+ console.warn([
36
+ '[kempo-oauth] SETTINGS_ENCRYPTION_KEY is not set, so client secrets cannot be stored.',
37
+ ' Generate one with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"',
38
+ ' Put it in .env and restart. Back it up somewhere — losing it makes every stored secret on the site unrecoverable.',
39
+ ].join('\n'));
40
+ return;
41
+ }
42
+
43
+ console.log([
44
+ '[kempo-oauth] Installed. Add a provider at /admin/extension/kempo-oauth/.',
45
+ ' The buttons appear under the login and register forms. If your site\'s own login and register pages',
46
+ ' predate the "login-form-after" / "register-form-after" slots, add them — see the README.',
47
+ ].join('\n'));
48
+ };