mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-05 09:04:56 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
776ca2c702 | ||
|
|
903e0c4d71 | ||
|
|
f29e47e9d2 | ||
|
|
6e6b85098e | ||
|
|
cf14accd2b | ||
|
|
cf4e37c584 | ||
|
|
69cdeedc4e | ||
|
|
0495fac6f1 | ||
|
|
5cac8971eb | ||
|
|
1196101226 |
2
.github/helm/affine/Chart.yaml
vendored
2
.github/helm/affine/Chart.yaml
vendored
@@ -3,4 +3,4 @@ name: affine
|
||||
description: AFFiNE cloud chart
|
||||
type: application
|
||||
version: 0.0.0
|
||||
appVersion: "0.25.5"
|
||||
appVersion: "0.25.7"
|
||||
|
||||
2
.github/helm/affine/charts/doc/Chart.yaml
vendored
2
.github/helm/affine/charts/doc/Chart.yaml
vendored
@@ -3,7 +3,7 @@ name: doc
|
||||
description: AFFiNE doc server
|
||||
type: application
|
||||
version: 0.0.0
|
||||
appVersion: "0.25.5"
|
||||
appVersion: "0.25.7"
|
||||
dependencies:
|
||||
- name: gcloud-sql-proxy
|
||||
version: 0.0.0
|
||||
|
||||
@@ -3,7 +3,7 @@ name: graphql
|
||||
description: AFFiNE GraphQL server
|
||||
type: application
|
||||
version: 0.0.0
|
||||
appVersion: "0.25.5"
|
||||
appVersion: "0.25.7"
|
||||
dependencies:
|
||||
- name: gcloud-sql-proxy
|
||||
version: 0.0.0
|
||||
|
||||
@@ -3,7 +3,7 @@ name: renderer
|
||||
description: AFFiNE renderer server
|
||||
type: application
|
||||
version: 0.0.0
|
||||
appVersion: "0.25.5"
|
||||
appVersion: "0.25.7"
|
||||
dependencies:
|
||||
- name: gcloud-sql-proxy
|
||||
version: 0.0.0
|
||||
|
||||
2
.github/helm/affine/charts/sync/Chart.yaml
vendored
2
.github/helm/affine/charts/sync/Chart.yaml
vendored
@@ -3,7 +3,7 @@ name: sync
|
||||
description: AFFiNE Sync Server
|
||||
type: application
|
||||
version: 0.0.0
|
||||
appVersion: "0.25.5"
|
||||
appVersion: "0.25.7"
|
||||
dependencies:
|
||||
- name: gcloud-sql-proxy
|
||||
version: 0.0.0
|
||||
|
||||
9
Cargo.lock
generated
9
Cargo.lock
generated
@@ -125,18 +125,23 @@ dependencies = [
|
||||
"affine_media_capture",
|
||||
"affine_nbstore",
|
||||
"affine_sqlite_v1",
|
||||
"chrono",
|
||||
"napi",
|
||||
"napi-build",
|
||||
"napi-derive",
|
||||
"once_cell",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"thiserror 2.0.12",
|
||||
"tokio",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "affine_nbstore"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"affine_common",
|
||||
"affine_schema",
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -144,10 +149,14 @@ dependencies = [
|
||||
"napi",
|
||||
"napi-build",
|
||||
"napi-derive",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"thiserror 2.0.12",
|
||||
"tokio",
|
||||
"uniffi",
|
||||
"uuid",
|
||||
"y-octo",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -296,7 +296,7 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5",
|
||||
"version": "0.25.7",
|
||||
"devDependencies": {
|
||||
"@vanilla-extract/vite-plugin": "^5.0.0",
|
||||
"msw": "^2.8.4",
|
||||
|
||||
@@ -41,5 +41,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -45,5 +45,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -45,5 +45,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -48,5 +48,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -42,5 +42,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -48,5 +48,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -39,5 +39,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -43,5 +43,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -49,5 +49,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -49,5 +49,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -44,5 +44,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -44,5 +44,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -46,5 +46,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -46,5 +46,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -49,5 +49,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -42,5 +42,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -67,5 +67,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -45,5 +45,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -46,5 +46,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -42,5 +42,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -82,5 +82,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export type MenuInputData = {
|
||||
class?: string;
|
||||
onComplete?: (value: string) => void;
|
||||
onChange?: (value: string) => void;
|
||||
onBlur?: (value: string) => void;
|
||||
disableAutoFocus?: boolean;
|
||||
};
|
||||
|
||||
@@ -49,6 +50,10 @@ export class MenuInput extends MenuFocusable {
|
||||
this.data.onChange?.(this.inputRef.value);
|
||||
};
|
||||
|
||||
private readonly onBlur = () => {
|
||||
this.data.onBlur?.(this.inputRef.value);
|
||||
};
|
||||
|
||||
private readonly onInput = (e: InputEvent) => {
|
||||
e.stopPropagation();
|
||||
if (e.isComposing) return;
|
||||
@@ -109,6 +114,7 @@ export class MenuInput extends MenuFocusable {
|
||||
@focus="${() => {
|
||||
this.menu.setFocusOnly(this);
|
||||
}}"
|
||||
@blur="${this.onBlur}"
|
||||
@input="${this.onInput}"
|
||||
placeholder="${this.data.placeholder ?? ''}"
|
||||
@keypress="${this.stopPropagation}"
|
||||
@@ -215,6 +221,7 @@ export const menuInputItems = {
|
||||
prefix?: TemplateResult;
|
||||
onComplete?: (value: string) => void;
|
||||
onChange?: (value: string) => void;
|
||||
onBlur?: (value: string) => void;
|
||||
class?: string;
|
||||
style?: Readonly<StyleInfo>;
|
||||
}) =>
|
||||
@@ -228,6 +235,7 @@ export const menuInputItems = {
|
||||
class: config.class,
|
||||
onComplete: config.onComplete,
|
||||
onChange: config.onChange,
|
||||
onBlur: config.onBlur,
|
||||
};
|
||||
const style = styleMap({
|
||||
display: 'flex',
|
||||
|
||||
@@ -46,5 +46,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -1,25 +1,10 @@
|
||||
import { menu } from '@blocksuite/affine-components/context-menu';
|
||||
import { IS_MOBILE } from '@blocksuite/global/env';
|
||||
import { html } from 'lit/static-html.js';
|
||||
|
||||
import { renderUniLit } from '../utils/uni-component/index.js';
|
||||
import type { Property } from '../view-manager/property.js';
|
||||
|
||||
export const inputConfig = (property: Property) => {
|
||||
if (IS_MOBILE) {
|
||||
return menu.input({
|
||||
prefix: html`
|
||||
<div class="affine-database-column-type-menu-icon">
|
||||
${renderUniLit(property.icon)}
|
||||
</div>
|
||||
`,
|
||||
initialValue: property.name$.value,
|
||||
placeholder: 'Property name',
|
||||
onChange: text => {
|
||||
property.nameSet(text);
|
||||
},
|
||||
});
|
||||
}
|
||||
return menu.input({
|
||||
prefix: html`
|
||||
<div class="affine-database-column-type-menu-icon">
|
||||
@@ -28,7 +13,7 @@ export const inputConfig = (property: Property) => {
|
||||
`,
|
||||
initialValue: property.name$.value,
|
||||
placeholder: 'Property name',
|
||||
onComplete: text => {
|
||||
onBlur: text => {
|
||||
property.nameSet(text);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -26,5 +26,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -42,5 +42,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -35,5 +35,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -40,5 +40,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -42,5 +42,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -41,5 +41,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -43,5 +43,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -44,5 +44,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -44,5 +44,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -45,5 +45,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -51,5 +51,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -45,5 +45,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -42,5 +42,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -44,5 +44,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -44,5 +44,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -43,5 +43,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -25,5 +25,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -42,5 +42,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -47,5 +47,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -50,5 +50,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -44,5 +44,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -42,5 +42,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -56,5 +56,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -43,5 +43,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -30,5 +30,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -41,5 +41,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -75,5 +75,5 @@
|
||||
"devDependencies": {
|
||||
"vitest": "3.1.3"
|
||||
},
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -45,5 +45,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -34,5 +34,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -36,5 +36,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -40,5 +40,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -38,5 +38,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -36,5 +36,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -34,5 +34,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -55,5 +55,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -42,5 +42,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -37,5 +37,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -37,5 +37,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -35,5 +35,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -30,5 +30,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -36,5 +36,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -38,5 +38,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -35,5 +35,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -17,5 +17,5 @@
|
||||
"dependencies": {
|
||||
"@blocksuite/affine": "workspace:*"
|
||||
},
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -64,5 +64,5 @@
|
||||
"devDependencies": {
|
||||
"vitest": "3.1.3"
|
||||
},
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -47,5 +47,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -42,5 +42,5 @@
|
||||
"!dist/__tests__",
|
||||
"shim.d.ts"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -33,5 +33,5 @@
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -46,5 +46,5 @@
|
||||
"vite-plugin-wasm": "^3.4.1",
|
||||
"vitest": "3.1.3"
|
||||
},
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -46,5 +46,5 @@
|
||||
"vite-plugin-wasm": "^3.3.0",
|
||||
"vite-plugin-web-components-hmr": "^0.1.3"
|
||||
},
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -19,5 +19,5 @@
|
||||
],
|
||||
"ext": "ts,md,json"
|
||||
},
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@affine/monorepo",
|
||||
"version": "0.25.5",
|
||||
"version": "0.25.7",
|
||||
"private": true,
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -7,7 +7,7 @@ version = "1.0.0"
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
affine_common = { workspace = true, features = ["doc-loader"] }
|
||||
affine_common = { workspace = true, features = ["doc-loader", "hashcash"] }
|
||||
chrono = { workspace = true }
|
||||
file-format = { workspace = true }
|
||||
infer = { workspace = true }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@affine/server-native",
|
||||
"version": "0.25.5",
|
||||
"version": "0.25.7",
|
||||
"engines": {
|
||||
"node": ">= 10.16.0 < 11 || >= 11.8.0"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@affine/server",
|
||||
"private": true,
|
||||
"version": "0.25.5",
|
||||
"version": "0.25.7",
|
||||
"description": "Affine Node.js server",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -95,6 +95,7 @@
|
||||
"http-errors": "^2.0.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"is-mobile": "^5.0.0",
|
||||
"jose": "^6.1.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"keyv": "^5.2.2",
|
||||
"lodash-es": "^4.17.21",
|
||||
|
||||
@@ -331,7 +331,6 @@ function mockOAuthProvider(
|
||||
clientNonce,
|
||||
});
|
||||
|
||||
// @ts-expect-error mock
|
||||
Sinon.stub(provider, 'getToken').resolves({ accessToken: '1' });
|
||||
Sinon.stub(provider, 'getUser').resolves({
|
||||
id: '1',
|
||||
|
||||
@@ -4,7 +4,7 @@ import { defineModuleConfig, JSONSchema } from '../../base';
|
||||
|
||||
export interface OAuthProviderConfig {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
clientSecret?: string;
|
||||
args?: Record<string, string>;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export type OIDCArgs = {
|
||||
claim_id?: string;
|
||||
claim_email?: string;
|
||||
claim_name?: string;
|
||||
claim_email_verified?: string;
|
||||
};
|
||||
|
||||
export interface OAuthOIDCProviderConfig extends OAuthProviderConfig {
|
||||
@@ -88,6 +89,7 @@ defineModuleConfig('oauth', {
|
||||
claim_id: z.string().optional(),
|
||||
claim_email: z.string().optional(),
|
||||
claim_name: z.string().optional(),
|
||||
claim_email_verified: z.string().optional(),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -65,18 +65,37 @@ export class OAuthController {
|
||||
throw new UnknownOauthProvider({ name: unknownProviderName });
|
||||
}
|
||||
|
||||
const pkce = provider.requiresPkce ? this.oauth.createPkcePair() : null;
|
||||
|
||||
const state = await this.oauth.saveOAuthState({
|
||||
provider: providerName,
|
||||
redirectUri,
|
||||
client,
|
||||
clientNonce,
|
||||
...(pkce
|
||||
? {
|
||||
pkce: {
|
||||
codeVerifier: pkce.codeVerifier,
|
||||
codeChallengeMethod: pkce.codeChallengeMethod,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
const stateStr = JSON.stringify({
|
||||
const statePayload: Record<string, unknown> = {
|
||||
state,
|
||||
client,
|
||||
provider: unknownProviderName,
|
||||
});
|
||||
};
|
||||
|
||||
if (pkce) {
|
||||
statePayload.pkce = {
|
||||
codeChallenge: pkce.codeChallenge,
|
||||
codeChallengeMethod: pkce.codeChallengeMethod,
|
||||
};
|
||||
}
|
||||
|
||||
const stateStr = JSON.stringify(statePayload);
|
||||
|
||||
return {
|
||||
url: provider.getAuthUrl(stateStr, clientNonce),
|
||||
@@ -125,6 +144,9 @@ export class OAuthController {
|
||||
if (!state) {
|
||||
throw new OauthStateExpired();
|
||||
}
|
||||
if (!state.token) {
|
||||
state.token = stateStr;
|
||||
}
|
||||
|
||||
if (
|
||||
state.provider === OAuthProviderName.Apple &&
|
||||
@@ -173,7 +195,7 @@ export class OAuthController {
|
||||
|
||||
let tokens: Tokens;
|
||||
try {
|
||||
tokens = await provider.getToken(code);
|
||||
tokens = await provider.getToken(code, state);
|
||||
} catch (err) {
|
||||
let rayBodyString = '';
|
||||
if (req.rawBody) {
|
||||
@@ -238,6 +260,7 @@ export class OAuthController {
|
||||
}
|
||||
|
||||
const user = await this.models.user.fulfill(externalAccount.email, {
|
||||
name: externalAccount.name,
|
||||
avatarUrl: externalAccount.avatarUrl,
|
||||
});
|
||||
|
||||
|
||||
@@ -2,13 +2,15 @@ import { JsonWebKey } from 'node:crypto';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import jwt, { type JwtPayload } from 'jsonwebtoken';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
InternalServerError,
|
||||
InvalidOauthCallbackCode,
|
||||
InvalidAuthState,
|
||||
URLHelper,
|
||||
} from '../../../base';
|
||||
import { OAuthProviderName } from '../config';
|
||||
import type { OAuthState } from '../types';
|
||||
import { OAuthProvider, Tokens } from './def';
|
||||
|
||||
interface AuthTokenResponse {
|
||||
@@ -19,14 +21,75 @@ interface AuthTokenResponse {
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
const AppleProviderArgsSchema = z.object({
|
||||
privateKey: z.string().nonempty(),
|
||||
keyId: z.string().nonempty(),
|
||||
teamId: z.string().nonempty(),
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
export class AppleOAuthProvider extends OAuthProvider {
|
||||
provider = OAuthProviderName.Apple;
|
||||
private args: z.infer<typeof AppleProviderArgsSchema> | null = null;
|
||||
private _jwtCache: { token: string; expiresAt: number } | null = null;
|
||||
|
||||
constructor(private readonly url: URLHelper) {
|
||||
super();
|
||||
}
|
||||
|
||||
override get configured() {
|
||||
if (this.config && !this.args) {
|
||||
const result = AppleProviderArgsSchema.safeParse(this.config?.args);
|
||||
if (result.success) {
|
||||
this.args = result.data;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
!!this.config &&
|
||||
!!this.config.clientId &&
|
||||
(!!this.config.clientSecret || !!this.args)
|
||||
);
|
||||
}
|
||||
|
||||
private get clientSecret() {
|
||||
if (this.config.clientSecret) {
|
||||
return this.config.clientSecret;
|
||||
}
|
||||
|
||||
if (!this.args) {
|
||||
throw new Error('Missing Apple OAuth configuration');
|
||||
}
|
||||
|
||||
if (this._jwtCache && this._jwtCache.expiresAt > Date.now()) {
|
||||
return this._jwtCache.token;
|
||||
}
|
||||
|
||||
const { privateKey, keyId, teamId } = this.args;
|
||||
const expiresIn = 300; // 5 minutes
|
||||
|
||||
try {
|
||||
const token = jwt.sign({}, privateKey, {
|
||||
algorithm: 'ES256',
|
||||
keyid: keyId,
|
||||
expiresIn,
|
||||
issuer: teamId,
|
||||
audience: 'https://appleid.apple.com',
|
||||
subject: this.config.clientId,
|
||||
});
|
||||
|
||||
this._jwtCache = {
|
||||
token,
|
||||
expiresAt: Date.now() + (expiresIn - 30) * 1000,
|
||||
};
|
||||
|
||||
return token;
|
||||
} catch (e) {
|
||||
this.logger.error('Failed to generate Apple client secret JWT', e);
|
||||
throw new Error('Failed to generate client secret');
|
||||
}
|
||||
}
|
||||
|
||||
getAuthUrl(state: string, clientNonce?: string): string {
|
||||
return `https://appleid.apple.com/auth/authorize?${this.url.stringify({
|
||||
client_id: this.config.clientId,
|
||||
@@ -40,54 +103,39 @@ export class AppleOAuthProvider extends OAuthProvider {
|
||||
})}`;
|
||||
}
|
||||
|
||||
async getToken(code: string) {
|
||||
const response = await fetch('https://appleid.apple.com/auth/token', {
|
||||
method: 'POST',
|
||||
body: this.url.stringify({
|
||||
async getToken(code: string, _state: OAuthState) {
|
||||
const appleToken = await this.postFormJson<AuthTokenResponse>(
|
||||
'https://appleid.apple.com/auth/token',
|
||||
this.url.stringify({
|
||||
code,
|
||||
client_id: this.config.clientId,
|
||||
client_secret: this.config.clientSecret,
|
||||
client_secret: this.clientSecret,
|
||||
redirect_uri: this.url.link('/api/oauth/callback'),
|
||||
grant_type: 'authorization_code',
|
||||
}),
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const appleToken = (await response.json()) as AuthTokenResponse;
|
||||
|
||||
return {
|
||||
accessToken: appleToken.access_token,
|
||||
refreshToken: appleToken.refresh_token,
|
||||
expiresAt: new Date(Date.now() + appleToken.expires_in * 1000),
|
||||
idToken: appleToken.id_token,
|
||||
};
|
||||
} else {
|
||||
const body = await response.text();
|
||||
if (response.status < 500) {
|
||||
throw new InvalidOauthCallbackCode({ status: response.status, body });
|
||||
}
|
||||
throw new Error(
|
||||
`Server responded with non-success status ${response.status}, body: ${body}`
|
||||
);
|
||||
}
|
||||
return {
|
||||
accessToken: appleToken.access_token,
|
||||
refreshToken: appleToken.refresh_token,
|
||||
expiresAt: new Date(Date.now() + appleToken.expires_in * 1000),
|
||||
idToken: appleToken.id_token,
|
||||
};
|
||||
}
|
||||
|
||||
async getUser(
|
||||
tokens: Tokens & { idToken: string },
|
||||
state: { clientNonce: string }
|
||||
) {
|
||||
const keysReq = await fetch('https://appleid.apple.com/auth/keys', {
|
||||
method: 'GET',
|
||||
});
|
||||
const { keys } = (await keysReq.json()) as { keys: JsonWebKey[] };
|
||||
async getUser(tokens: Tokens, state: OAuthState) {
|
||||
if (!tokens.idToken) {
|
||||
throw new InvalidAuthState();
|
||||
}
|
||||
const { keys } = await this.fetchJson<{ keys: JsonWebKey[] }>(
|
||||
'https://appleid.apple.com/auth/keys',
|
||||
{ method: 'GET' },
|
||||
{ treatServerErrorAsInvalid: true }
|
||||
);
|
||||
|
||||
const payload = await new Promise<JwtPayload>((resolve, reject) => {
|
||||
jwt.verify(
|
||||
tokens.idToken,
|
||||
tokens.idToken!,
|
||||
(header, callback) => {
|
||||
const key = keys.find(key => key.kid === header.kid);
|
||||
if (!key) {
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { Config, OnEvent } from '../../../base';
|
||||
import {
|
||||
Config,
|
||||
InvalidOauthCallbackCode,
|
||||
InvalidOauthResponse,
|
||||
OnEvent,
|
||||
} from '../../../base';
|
||||
import { OAuthProviderName } from '../config';
|
||||
import { OAuthProviderFactory } from '../factory';
|
||||
import type { OAuthState } from '../types';
|
||||
|
||||
export interface OAuthAccount {
|
||||
id: string;
|
||||
@@ -16,6 +22,8 @@ export interface Tokens {
|
||||
scope?: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: Date;
|
||||
idToken?: string;
|
||||
tokenType?: string;
|
||||
}
|
||||
|
||||
export interface AuthOptions {
|
||||
@@ -29,8 +37,8 @@ export interface AuthOptions {
|
||||
export abstract class OAuthProvider {
|
||||
abstract provider: OAuthProviderName;
|
||||
abstract getAuthUrl(state: string, clientNonce?: string): string;
|
||||
abstract getToken(code: string): Promise<Tokens>;
|
||||
abstract getUser(tokens: Tokens, state: any): Promise<OAuthAccount>;
|
||||
abstract getToken(code: string, state: OAuthState): Promise<Tokens>;
|
||||
abstract getUser(tokens: Tokens, state: OAuthState): Promise<OAuthAccount>;
|
||||
|
||||
protected readonly logger = new Logger(this.constructor.name);
|
||||
@Inject() private readonly factory!: OAuthProviderFactory;
|
||||
@@ -65,4 +73,63 @@ export abstract class OAuthProvider {
|
||||
this.factory.unregister(this);
|
||||
}
|
||||
}
|
||||
|
||||
get requiresPkce() {
|
||||
return false;
|
||||
}
|
||||
|
||||
protected async fetchJson<T>(
|
||||
url: string,
|
||||
init?: RequestInit,
|
||||
options?: { treatServerErrorAsInvalid?: boolean }
|
||||
) {
|
||||
const response = await fetch(url, {
|
||||
headers: { Accept: 'application/json', ...init?.headers },
|
||||
...init,
|
||||
});
|
||||
|
||||
const body = await response.text();
|
||||
if (!response.ok) {
|
||||
if (response.status < 500 || options?.treatServerErrorAsInvalid) {
|
||||
throw new InvalidOauthCallbackCode({ status: response.status, body });
|
||||
}
|
||||
throw new Error(
|
||||
`Server responded with non-success status ${response.status}, body: ${body}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!body) {
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(body) as T;
|
||||
} catch {
|
||||
throw new InvalidOauthResponse({
|
||||
reason: `Unable to parse JSON response from ${url}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected postFormJson<T>(
|
||||
url: string,
|
||||
body: string,
|
||||
options?: {
|
||||
headers?: Record<string, string>;
|
||||
treatServerErrorAsInvalid?: boolean;
|
||||
}
|
||||
) {
|
||||
return this.fetchJson<T>(
|
||||
url,
|
||||
{
|
||||
method: 'POST',
|
||||
body,
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...options?.headers,
|
||||
},
|
||||
},
|
||||
options
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { InvalidOauthCallbackCode, URLHelper } from '../../../base';
|
||||
import { URLHelper } from '../../../base';
|
||||
import { OAuthProviderName } from '../config';
|
||||
import { OAuthProvider, Tokens } from './def';
|
||||
import type { OAuthState } from '../types';
|
||||
import { OAuthAccount, OAuthProvider, Tokens } from './def';
|
||||
|
||||
interface AuthTokenResponse {
|
||||
access_token: string;
|
||||
@@ -35,64 +36,36 @@ export class GithubOAuthProvider extends OAuthProvider {
|
||||
})}`;
|
||||
}
|
||||
|
||||
async getToken(code: string) {
|
||||
const response = await fetch(
|
||||
async getToken(code: string, _state: OAuthState): Promise<Tokens> {
|
||||
const ghToken = await this.postFormJson<AuthTokenResponse>(
|
||||
'https://github.com/login/oauth/access_token',
|
||||
{
|
||||
method: 'POST',
|
||||
body: this.url.stringify({
|
||||
code,
|
||||
client_id: this.config.clientId,
|
||||
client_secret: this.config.clientSecret,
|
||||
redirect_uri: this.url.link('/oauth/callback'),
|
||||
}),
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
}
|
||||
this.url.stringify({
|
||||
code,
|
||||
client_id: this.config.clientId,
|
||||
client_secret: this.config.clientSecret,
|
||||
redirect_uri: this.url.link('/oauth/callback'),
|
||||
})
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const ghToken = (await response.json()) as AuthTokenResponse;
|
||||
|
||||
return {
|
||||
accessToken: ghToken.access_token,
|
||||
scope: ghToken.scope,
|
||||
};
|
||||
} else {
|
||||
const body = await response.text();
|
||||
if (response.status < 500) {
|
||||
throw new InvalidOauthCallbackCode({ status: response.status, body });
|
||||
}
|
||||
throw new Error(
|
||||
`Server responded with non-success status ${response.status}, body: ${body}`
|
||||
);
|
||||
}
|
||||
return {
|
||||
accessToken: ghToken.access_token,
|
||||
scope: ghToken.scope,
|
||||
};
|
||||
}
|
||||
|
||||
async getUser(tokens: Tokens) {
|
||||
const response = await fetch('https://api.github.com/user', {
|
||||
async getUser(tokens: Tokens, _state: OAuthState): Promise<OAuthAccount> {
|
||||
const user = await this.fetchJson<UserInfo>('https://api.github.com/user', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens.accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const user = (await response.json()) as UserInfo;
|
||||
|
||||
return {
|
||||
id: user.login,
|
||||
avatarUrl: user.avatar_url,
|
||||
email: user.email,
|
||||
};
|
||||
} else {
|
||||
throw new Error(
|
||||
`Server responded with non-success code ${
|
||||
response.status
|
||||
} ${await response.text()}`
|
||||
);
|
||||
}
|
||||
return {
|
||||
id: user.login,
|
||||
avatarUrl: user.avatar_url,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { InvalidOauthCallbackCode, URLHelper } from '../../../base';
|
||||
import { URLHelper } from '../../../base';
|
||||
import { OAuthProviderName } from '../config';
|
||||
import { OAuthProvider, Tokens } from './def';
|
||||
import type { OAuthState } from '../types';
|
||||
import { OAuthAccount, OAuthProvider, Tokens } from './def';
|
||||
|
||||
interface GoogleOAuthTokenResponse {
|
||||
access_token: string;
|
||||
@@ -40,44 +41,28 @@ export class GoogleOAuthProvider extends OAuthProvider {
|
||||
})}`;
|
||||
}
|
||||
|
||||
async getToken(code: string) {
|
||||
const response = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
body: this.url.stringify({
|
||||
async getToken(code: string, _state: OAuthState): Promise<Tokens> {
|
||||
const gToken = await this.postFormJson<GoogleOAuthTokenResponse>(
|
||||
'https://oauth2.googleapis.com/token',
|
||||
this.url.stringify({
|
||||
code,
|
||||
client_id: this.config.clientId,
|
||||
client_secret: this.config.clientSecret,
|
||||
redirect_uri: this.url.link('/oauth/callback'),
|
||||
grant_type: 'authorization_code',
|
||||
}),
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const ghToken = (await response.json()) as GoogleOAuthTokenResponse;
|
||||
|
||||
return {
|
||||
accessToken: ghToken.access_token,
|
||||
refreshToken: ghToken.refresh_token,
|
||||
expiresAt: new Date(Date.now() + ghToken.expires_in * 1000),
|
||||
scope: ghToken.scope,
|
||||
};
|
||||
} else {
|
||||
const body = await response.text();
|
||||
if (response.status < 500) {
|
||||
throw new InvalidOauthCallbackCode({ status: response.status, body });
|
||||
}
|
||||
throw new Error(
|
||||
`Server responded with non-success status ${response.status}, body: ${body}`
|
||||
);
|
||||
}
|
||||
return {
|
||||
accessToken: gToken.access_token,
|
||||
refreshToken: gToken.refresh_token,
|
||||
expiresAt: new Date(Date.now() + gToken.expires_in * 1000),
|
||||
scope: gToken.scope,
|
||||
};
|
||||
}
|
||||
|
||||
async getUser(tokens: Tokens) {
|
||||
const response = await fetch(
|
||||
async getUser(tokens: Tokens, _state: OAuthState): Promise<OAuthAccount> {
|
||||
const user = await this.fetchJson<UserInfo>(
|
||||
'https://www.googleapis.com/oauth2/v2/userinfo',
|
||||
{
|
||||
method: 'GET',
|
||||
@@ -87,20 +72,11 @@ export class GoogleOAuthProvider extends OAuthProvider {
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const user = (await response.json()) as UserInfo;
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
avatarUrl: user.picture,
|
||||
email: user.email,
|
||||
};
|
||||
} else {
|
||||
throw new Error(
|
||||
`Server responded with non-success code ${
|
||||
response.status
|
||||
} ${await response.text()}`
|
||||
);
|
||||
}
|
||||
return {
|
||||
id: user.id,
|
||||
avatarUrl: user.picture,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,34 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { createRemoteJWKSet, type JWTPayload, jwtVerify } from 'jose';
|
||||
import { omit } from 'lodash-es';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
InvalidOauthCallbackCode,
|
||||
InvalidAuthState,
|
||||
InvalidOauthResponse,
|
||||
URLHelper,
|
||||
} from '../../../base';
|
||||
import { OAuthOIDCProviderConfig, OAuthProviderName } from '../config';
|
||||
import type { OAuthState } from '../types';
|
||||
import { OAuthAccount, OAuthProvider, Tokens } from './def';
|
||||
|
||||
const StatePayloadSchema = z.object({
|
||||
state: z.string().optional(),
|
||||
pkce: z
|
||||
.object({
|
||||
codeChallenge: z.string(),
|
||||
codeChallengeMethod: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const OIDCTokenSchema = z.object({
|
||||
access_token: z.string(),
|
||||
expires_in: z.number(),
|
||||
refresh_token: z.string(),
|
||||
expires_in: z.number().positive().optional(),
|
||||
refresh_token: z.string().optional(),
|
||||
scope: z.string().optional(),
|
||||
token_type: z.string(),
|
||||
id_token: z.string(),
|
||||
});
|
||||
|
||||
const OIDCUserInfoSchema = z
|
||||
@@ -23,7 +36,8 @@ const OIDCUserInfoSchema = z
|
||||
sub: z.string(),
|
||||
preferred_username: z.string().optional(),
|
||||
email: z.string().email(),
|
||||
name: z.string(),
|
||||
name: z.string().optional(),
|
||||
email_verified: z.boolean().optional(),
|
||||
groups: z.array(z.string()).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
@@ -32,6 +46,8 @@ const OIDCConfigurationSchema = z.object({
|
||||
authorization_endpoint: z.string().url(),
|
||||
token_endpoint: z.string().url(),
|
||||
userinfo_endpoint: z.string().url(),
|
||||
issuer: z.string().url(),
|
||||
jwks_uri: z.string().url(),
|
||||
});
|
||||
|
||||
type OIDCConfiguration = z.infer<typeof OIDCConfigurationSchema>;
|
||||
@@ -40,11 +56,16 @@ type OIDCConfiguration = z.infer<typeof OIDCConfigurationSchema>;
|
||||
export class OIDCProvider extends OAuthProvider {
|
||||
override provider = OAuthProviderName.OIDC;
|
||||
#endpoints: OIDCConfiguration | null = null;
|
||||
#jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
|
||||
|
||||
constructor(private readonly url: URLHelper) {
|
||||
super();
|
||||
}
|
||||
|
||||
override get requiresPkce() {
|
||||
return true;
|
||||
}
|
||||
|
||||
private get endpoints() {
|
||||
if (!this.#endpoints) {
|
||||
throw new Error('OIDC provider is not configured');
|
||||
@@ -52,16 +73,30 @@ export class OIDCProvider extends OAuthProvider {
|
||||
return this.#endpoints;
|
||||
}
|
||||
|
||||
private get jwks() {
|
||||
if (!this.#jwks) {
|
||||
throw new Error('OIDC provider is not configured');
|
||||
}
|
||||
return this.#jwks;
|
||||
}
|
||||
|
||||
override get configured() {
|
||||
return this.#endpoints !== null;
|
||||
return this.#endpoints !== null && this.#jwks !== null;
|
||||
}
|
||||
|
||||
protected override setup() {
|
||||
const validate = async () => {
|
||||
this.#endpoints = null;
|
||||
this.#jwks = null;
|
||||
|
||||
if (super.configured) {
|
||||
const config = this.config as OAuthOIDCProviderConfig;
|
||||
if (!config.issuer) {
|
||||
this.logger.error('Missing OIDC issuer configuration');
|
||||
super.setup();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${config.issuer}/.well-known/openid-configuration`,
|
||||
@@ -72,7 +107,20 @@ export class OIDCProvider extends OAuthProvider {
|
||||
);
|
||||
|
||||
if (res.ok) {
|
||||
this.#endpoints = OIDCConfigurationSchema.parse(await res.json());
|
||||
const configuration = OIDCConfigurationSchema.parse(
|
||||
await res.json()
|
||||
);
|
||||
if (
|
||||
this.normalizeIssuer(config.issuer) !==
|
||||
this.normalizeIssuer(configuration.issuer)
|
||||
) {
|
||||
this.logger.error(
|
||||
`OIDC issuer mismatch, expected ${config.issuer}, got ${configuration.issuer}`
|
||||
);
|
||||
} else {
|
||||
this.#endpoints = configuration;
|
||||
this.#jwks = createRemoteJWKSet(new URL(configuration.jwks_uri));
|
||||
}
|
||||
} else {
|
||||
this.logger.error(`Invalid OIDC issuer ${config.issuer}`);
|
||||
}
|
||||
@@ -90,89 +138,240 @@ export class OIDCProvider extends OAuthProvider {
|
||||
}
|
||||
|
||||
getAuthUrl(state: string): string {
|
||||
return `${this.endpoints.authorization_endpoint}?${this.url.stringify({
|
||||
const parsedState = this.parseStatePayload(state);
|
||||
const nonce = parsedState?.state ?? state;
|
||||
const pkce = parsedState?.pkce;
|
||||
|
||||
if (
|
||||
this.requiresPkce &&
|
||||
(!pkce?.codeChallenge || !pkce.codeChallengeMethod)
|
||||
) {
|
||||
throw new InvalidOauthResponse({
|
||||
reason: 'Missing PKCE challenge for OIDC authorization request',
|
||||
});
|
||||
}
|
||||
|
||||
const query: JWTPayload = {
|
||||
client_id: this.config.clientId,
|
||||
redirect_uri: this.url.link('/oauth/callback'),
|
||||
scope: this.config.args?.scope || 'openid profile email',
|
||||
scope: this.resolveScope(this.config.args?.scope),
|
||||
response_type: 'code',
|
||||
...omit(this.config.args, 'claim_id', 'claim_email', 'claim_name'),
|
||||
...omit(
|
||||
this.config.args,
|
||||
'claim_id',
|
||||
'claim_email',
|
||||
'claim_name',
|
||||
'claim_email_verified'
|
||||
),
|
||||
state,
|
||||
})}`;
|
||||
nonce,
|
||||
};
|
||||
|
||||
if (pkce) {
|
||||
query.code_challenge = pkce.codeChallenge;
|
||||
query.code_challenge_method = pkce.codeChallengeMethod;
|
||||
}
|
||||
|
||||
return `${this.endpoints.authorization_endpoint}?${this.url.stringify(
|
||||
query
|
||||
)}`;
|
||||
}
|
||||
|
||||
async getToken(code: string): Promise<Tokens> {
|
||||
const res = await fetch(this.endpoints.token_endpoint, {
|
||||
method: 'POST',
|
||||
body: this.url.stringify({
|
||||
async getToken(code: string, state: OAuthState): Promise<Tokens> {
|
||||
if (this.requiresPkce && !state.pkce?.codeVerifier) {
|
||||
throw new InvalidAuthState();
|
||||
}
|
||||
|
||||
const data = await this.postFormJson<unknown>(
|
||||
this.endpoints.token_endpoint,
|
||||
this.url.stringify({
|
||||
code,
|
||||
client_id: this.config.clientId,
|
||||
client_secret: this.config.clientSecret,
|
||||
redirect_uri: this.url.link('/oauth/callback'),
|
||||
grant_type: 'authorization_code',
|
||||
...(state.pkce?.codeVerifier
|
||||
? { code_verifier: state.pkce.codeVerifier }
|
||||
: {}),
|
||||
}),
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
});
|
||||
{ treatServerErrorAsInvalid: true }
|
||||
);
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const tokens = OIDCTokenSchema.parse(data);
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresAt: new Date(Date.now() + tokens.expires_in * 1000),
|
||||
scope: tokens.scope,
|
||||
};
|
||||
const tokens = OIDCTokenSchema.parse(data);
|
||||
if (!tokens.id_token) {
|
||||
throw new InvalidOauthResponse({
|
||||
reason: 'Missing id_token in OIDC token response',
|
||||
});
|
||||
}
|
||||
|
||||
throw new InvalidOauthCallbackCode({
|
||||
status: res.status,
|
||||
body: await res.text(),
|
||||
});
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresAt: tokens.expires_in
|
||||
? new Date(Date.now() + tokens.expires_in * 1000)
|
||||
: undefined,
|
||||
scope: tokens.scope,
|
||||
idToken: tokens.id_token,
|
||||
tokenType: tokens.token_type,
|
||||
};
|
||||
}
|
||||
|
||||
async getUser(tokens: Tokens): Promise<OAuthAccount> {
|
||||
const res = await fetch(this.endpoints.userinfo_endpoint, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${tokens.accessToken}`,
|
||||
},
|
||||
});
|
||||
private parseStatePayload(state: string) {
|
||||
if (!state) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (res.ok) {
|
||||
const body = await res.json();
|
||||
const user = OIDCUserInfoSchema.parse(body);
|
||||
try {
|
||||
const stateObj = JSON.parse(state);
|
||||
return StatePayloadSchema.parse(stateObj);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const args = this.config.args ?? {};
|
||||
private resolveScope(scope?: string) {
|
||||
if (!scope) {
|
||||
return 'openid profile email';
|
||||
}
|
||||
|
||||
const claimsMap = {
|
||||
id: args.claim_id || 'preferred_username',
|
||||
email: args.claim_email || 'email',
|
||||
name: args.claim_name || 'name',
|
||||
};
|
||||
const segments = scope.split(/\s+/).filter(Boolean);
|
||||
if (!segments.includes('openid')) {
|
||||
segments.unshift('openid');
|
||||
}
|
||||
|
||||
const identities = {
|
||||
id: user[claimsMap.id] as string,
|
||||
email: user[claimsMap.email] as string,
|
||||
};
|
||||
return segments.join(' ');
|
||||
}
|
||||
|
||||
if (!identities.id || !identities.email) {
|
||||
throw new InvalidOauthResponse({
|
||||
reason: `Missing required claims: ${Object.keys(identities)
|
||||
.filter(key => !identities[key as keyof typeof identities])
|
||||
.join(', ')}`,
|
||||
});
|
||||
private normalizeIssuer(issuer: string) {
|
||||
return issuer.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
private async verifyIdToken(idToken: string, nonce: string) {
|
||||
try {
|
||||
const { payload } = await jwtVerify(idToken, this.jwks, {
|
||||
issuer: this.endpoints.issuer,
|
||||
audience: this.config.clientId,
|
||||
});
|
||||
|
||||
if (!payload.nonce || payload.nonce !== nonce) {
|
||||
throw new InvalidAuthState();
|
||||
}
|
||||
|
||||
return identities;
|
||||
return payload;
|
||||
} catch (err) {
|
||||
this.logger.warn('Failed to verify OIDC id token', err);
|
||||
throw new InvalidAuthState();
|
||||
}
|
||||
}
|
||||
|
||||
private extractBoolean(value: unknown): boolean | undefined {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
|
||||
throw new InvalidOauthCallbackCode({
|
||||
status: res.status,
|
||||
body: await res.text(),
|
||||
});
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.toLowerCase();
|
||||
if (['true', '1', 'yes'].includes(normalized)) {
|
||||
return true;
|
||||
}
|
||||
if (['false', '0', 'no'].includes(normalized)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private extractString(value: unknown): string | undefined {
|
||||
if (typeof value === 'string' && value.length > 0) {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async getUser(tokens: Tokens, state: OAuthState): Promise<OAuthAccount> {
|
||||
if (!tokens.idToken) {
|
||||
throw new InvalidOauthResponse({
|
||||
reason: 'Missing id_token in OIDC token response',
|
||||
});
|
||||
}
|
||||
|
||||
if (!state.token) {
|
||||
throw new InvalidAuthState();
|
||||
}
|
||||
|
||||
const idTokenClaims = await this.verifyIdToken(tokens.idToken, state.token);
|
||||
|
||||
const rawUser = await this.fetchJson<unknown>(
|
||||
this.endpoints.userinfo_endpoint,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens.accessToken}`,
|
||||
},
|
||||
},
|
||||
{ treatServerErrorAsInvalid: true }
|
||||
);
|
||||
const user = OIDCUserInfoSchema.parse(rawUser);
|
||||
|
||||
if (!user.sub || !idTokenClaims.sub) {
|
||||
throw new InvalidOauthResponse({
|
||||
reason: 'Missing subject claim in OIDC response',
|
||||
});
|
||||
} else if (user.sub !== idTokenClaims.sub) {
|
||||
throw new InvalidOauthResponse({
|
||||
reason: 'Subject mismatch between ID token and userinfo response',
|
||||
});
|
||||
}
|
||||
|
||||
const args = this.config.args ?? {};
|
||||
|
||||
const claimsMap = {
|
||||
id: args.claim_id || 'sub',
|
||||
email: args.claim_email || 'email',
|
||||
name: args.claim_name || 'name',
|
||||
emailVerified: args.claim_email_verified || 'email_verified',
|
||||
};
|
||||
|
||||
const accountId =
|
||||
this.extractString(user[claimsMap.id]) ?? idTokenClaims.sub;
|
||||
const email =
|
||||
this.extractString(user[claimsMap.email]) ||
|
||||
this.extractString(idTokenClaims.email);
|
||||
const emailVerified =
|
||||
this.extractBoolean(user[claimsMap.emailVerified]) ??
|
||||
this.extractBoolean(idTokenClaims.email_verified);
|
||||
|
||||
if (!accountId) {
|
||||
throw new InvalidOauthResponse({
|
||||
reason: 'Missing required claim for user identifier',
|
||||
});
|
||||
}
|
||||
|
||||
if (!email) {
|
||||
throw new InvalidOauthResponse({
|
||||
reason: 'Missing required claim for email',
|
||||
});
|
||||
}
|
||||
|
||||
if (emailVerified === false) {
|
||||
throw new InvalidOauthResponse({
|
||||
reason: 'Email for this account is not verified',
|
||||
});
|
||||
}
|
||||
|
||||
const account: OAuthAccount = {
|
||||
id: accountId,
|
||||
email,
|
||||
};
|
||||
|
||||
const name =
|
||||
this.extractString(user[claimsMap.name]) ||
|
||||
this.extractString(idTokenClaims.name);
|
||||
if (name) {
|
||||
account.name = name;
|
||||
}
|
||||
|
||||
return account;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { SessionCache } from '../../base';
|
||||
import { OAuthProviderName } from './config';
|
||||
import { OAuthProviderFactory } from './factory';
|
||||
import { OAuthPkceChallenge, OAuthState } from './types';
|
||||
|
||||
const OAUTH_STATE_KEY = 'OAUTH_STATE';
|
||||
|
||||
interface OAuthState {
|
||||
redirectUri?: string;
|
||||
client?: string;
|
||||
clientNonce?: string;
|
||||
provider: OAuthProviderName;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OAuthService {
|
||||
constructor(
|
||||
@@ -28,7 +21,8 @@ export class OAuthService {
|
||||
|
||||
async saveOAuthState(state: OAuthState) {
|
||||
const token = randomUUID();
|
||||
await this.cache.set(`${OAUTH_STATE_KEY}:${token}`, state, {
|
||||
const payload: OAuthState = { ...state, token };
|
||||
await this.cache.set(`${OAUTH_STATE_KEY}:${token}`, payload, {
|
||||
ttl: 3600 * 3 * 1000 /* 3 hours */,
|
||||
});
|
||||
|
||||
@@ -42,4 +36,28 @@ export class OAuthService {
|
||||
availableOAuthProviders() {
|
||||
return this.providerFactory.providers;
|
||||
}
|
||||
|
||||
createPkcePair(): OAuthPkceChallenge {
|
||||
const codeVerifier = this.randomBase64Url(96);
|
||||
const hash = createHash('sha256').update(codeVerifier).digest();
|
||||
const codeChallenge = this.base64UrlEncode(hash);
|
||||
|
||||
return {
|
||||
codeVerifier,
|
||||
codeChallenge,
|
||||
codeChallengeMethod: 'S256',
|
||||
};
|
||||
}
|
||||
|
||||
private randomBase64Url(byteLength: number) {
|
||||
return this.base64UrlEncode(randomBytes(byteLength));
|
||||
}
|
||||
|
||||
private base64UrlEncode(buffer: Buffer) {
|
||||
return buffer
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
}
|
||||
}
|
||||
|
||||
19
packages/backend/server/src/plugins/oauth/types.ts
Normal file
19
packages/backend/server/src/plugins/oauth/types.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { OAuthProviderName } from './config';
|
||||
|
||||
export interface OAuthPkceState {
|
||||
codeVerifier: string;
|
||||
codeChallengeMethod: 'S256';
|
||||
}
|
||||
|
||||
export interface OAuthPkceChallenge extends OAuthPkceState {
|
||||
codeChallenge: string;
|
||||
}
|
||||
|
||||
export interface OAuthState {
|
||||
redirectUri?: string;
|
||||
client?: string;
|
||||
clientNonce?: string;
|
||||
provider: OAuthProviderName;
|
||||
pkce?: OAuthPkceState;
|
||||
token?: string;
|
||||
}
|
||||
@@ -12,5 +12,5 @@
|
||||
"@types/debug": "^4.1.12",
|
||||
"vitest": "3.1.3"
|
||||
},
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
2
packages/common/env/package.json
vendored
2
packages/common/env/package.json
vendored
@@ -22,5 +22,5 @@
|
||||
"dependencies": {
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"version": "0.25.5"
|
||||
"version": "0.25.7"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@affine/error",
|
||||
"version": "0.25.5",
|
||||
"version": "0.25.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@affine/graphql",
|
||||
"version": "0.25.5",
|
||||
"version": "0.25.7",
|
||||
"description": "Autogenerated GraphQL client for affine.pro",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user