Compare commits

..

26 Commits

Author SHA1 Message Date
github-actions[bot]
7f72aadff9 release(packages/nhost-js): 4.1.0 (#3586)
Co-authored-by: dbarrosop <dbarrosop@users.noreply.github.com>
2025-11-04 16:21:25 +01:00
github-actions[bot]
8faf9565bb release(services/storage): 0.9.0 (#3654)
Co-authored-by: dbarrosop <dbarrosop@users.noreply.github.com>
2025-11-04 16:21:16 +01:00
github-actions[bot]
7ac3f12852 release(services/auth): 0.43.0 (#3667)
Co-authored-by: dbarrosop <dbarrosop@users.noreply.github.com>
2025-11-04 16:21:10 +01:00
David Barroso
184a3ed190 feat(internal/lib): common oapi middleware for go services (#3663) 2025-11-04 16:17:41 +01:00
David Barroso
372c4e32d4 fix(ci): match the version exactly to avoid matching on pre-releases (#3666) 2025-11-04 15:54:01 +01:00
David Barroso
a68d261d8e fix(nhost-js): improvements to Session guard to avoid conflict with ProviderSession (#3662) 2025-11-04 10:53:37 +01:00
David Barroso
55bda3f56b fix(auth): dont mutate client URL (#3660) 2025-11-03 10:56:14 +01:00
David Barroso
2311e1dd77 feat(auth): if the callback state is wrong send back to the redirectTo as provider_state (#3649) 2025-10-31 12:13:35 +01:00
David Barroso
824ee142c4 chore(nixops): set system libraries consistently on darwin (#3656) 2025-10-31 11:06:38 +01:00
David Barroso
c662d063a7 chore(nixops): bump go to 1.25.3 and nixpkgs due to CVEs (#3652) 2025-10-30 16:37:52 +01:00
David Barroso
b518132349 chore(nhost-js): regenerate types (#3648) 2025-10-29 12:50:22 +01:00
David BM
b677d3768f fix(dashboard): update SQL editor to use correct hasura migrations API URL (#3645) 2025-10-28 15:58:25 +01:00
David Barroso
51ec151752 feat(auth): added endpoints to retrieve and refresh oauth2 providers' tokens (#3614) 2025-10-28 12:50:30 +01:00
David Barroso
223322d654 fix(ci): run pull_request_target workflows against PR (#3646) 2025-10-28 11:51:55 +01:00
David Barroso
add2c20c95 chore(nixops): bump nhost-cli (#3641) 2025-10-28 10:05:47 +01:00
github-actions[bot]
961bc5feea release(cli): 1.34.4 (#3644)
Co-authored-by: dbarrosop <dbarrosop@users.noreply.github.com>
2025-10-28 09:46:18 +01:00
David Barroso
0ca89974b9 fix(cli): update NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL correctly (#3643) 2025-10-28 09:44:14 +01:00
github-actions[bot]
e8d52859a3 release(cli): 1.34.3 (#3624)
Co-authored-by: dbarrosop <dbarrosop@users.noreply.github.com>
2025-10-27 16:05:01 +01:00
David Barroso
67740ebe3d chore(cli): bump nhost/dashboard to 2.40.0 (#3629)
Co-authored-by: dbarrosop <dbarrosop@users.noreply.github.com>
2025-10-27 16:03:07 +01:00
github-actions[bot]
d6f7b01aee release(dashboard): 2.40.0 (#3631)
Co-authored-by: dbarrosop <dbarrosop@users.noreply.github.com>
2025-10-27 15:23:14 +01:00
dependabot[bot]
0fc65df78d chore(ci): bump actions/upload-artifact from 4 to 5 (#3638)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-27 09:50:12 +01:00
dependabot[bot]
52e3db7f61 chore(ci): bump actions/download-artifact from 5 to 6 (#3639)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-27 08:51:05 +01:00
David Barroso
235449d68c chore(docs): update guidelines on the use of AI for contributions (#3637) 2025-10-27 08:46:10 +01:00
Jason Overmier
323834d212 feat(dashboard): allow configuring CSP header (#3627)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: David Barroso <dbarrosop@dravetech.com>
2025-10-23 12:05:04 +02:00
David Barroso
f7bd250f73 chore(ci): changed pull_request to pull_request_target for access to secrets (#3632) 2025-10-23 11:15:29 +02:00
David BM
579f9dbf31 chore(dashboard): various improvements to support ticket page (#3630)
Co-authored-by: robertkasza <robert.kasza@bishop-co.com>
2025-10-23 09:38:45 +02:00
214 changed files with 4181 additions and 2258 deletions

View File

@@ -7,6 +7,8 @@ assignees: ''
---
> **Note:** Bug reports that are clearly AI-generated will not be accepted and will be closed immediately. Please write your bug report in your own words.
**Describe the bug**
A clear and concise description of what the bug is.

View File

@@ -7,6 +7,8 @@ assignees: ''
---
> **Note:** Feature requests that are clearly AI-generated will not be accepted and will be closed immediately. Please write your feature request in your own words.
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]

View File

@@ -8,6 +8,8 @@
--- Delete everything below this line before submitting your PR ---
> **Note on AI-assisted contributions:** Contributions with the help of AI are permitted, but you are ultimately responsible for the quality of your submission and for ensuring it follows our contributing guidelines. **The PR description must be written in your own words and be clear and concise**. Please ensure you remove any superfluous code comments introduced by AI tools before submitting. PRs that clearly violate this rule will be closed without further review.
### PR title format
The PR title must follow the following pattern:
@@ -30,6 +32,7 @@ Where `PKG` is:
- `deps`: For changes to dependencies
- `docs`: For changes to the documentation
- `examples`: For changes to the examples
- `internal/lib`: For changes to Nhost's common libraries (internal)
- `mintlify-openapi`: For changes to the Mintlify OpenAPI tool
- `nhost-js`: For changes to the Nhost JavaScript SDK
- `nixops`: For changes to the NixOps

View File

@@ -17,7 +17,7 @@ runs:
# Define valid types and packages
VALID_TYPES="feat|fix|chore"
VALID_PKGS="auth|ci|cli|codegen|dashboard|deps|docs|examples|mintlify-openapi|nhost-js|nixops|storage"
VALID_PKGS="auth|ci|cli|codegen|dashboard|deps|docs|examples|internal\/lib|mintlify-openapi|nhost-js|nixops|storage"
# Check if title matches the pattern TYPE(PKG): SUMMARY
if [[ ! "$PR_TITLE" =~ ^(${VALID_TYPES})\((${VALID_PKGS})\):\ .+ ]]; then

View File

@@ -1,8 +1,7 @@
---
name: "auth: check and build"
on:
# pull_request_target:
pull_request:
pull_request_target:
paths:
- '.github/workflows/auth_checks.yaml'
- '.github/workflows/wf_check.yaml'
@@ -18,6 +17,7 @@ on:
- '.golangci.yaml'
- 'go.mod'
- 'go.sum'
- 'internal/lib/**'
- 'vendor/**'
# auth
@@ -49,7 +49,7 @@ jobs:
with:
NAME: auth
PATH: services/auth
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
secrets:
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
@@ -64,7 +64,7 @@ jobs:
with:
NAME: auth
PATH: services/auth
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
VERSION: 0.0.0-dev # we use a fixed version here to avoid unnecessary rebuilds
DOCKER: true
secrets:

View File

@@ -40,7 +40,7 @@ jobs:
cd ${{ matrix.project }}
TAG_NAME=$(make release-tag-name)
VERSION=$(nix develop .\#cliff -c make changelog-next-version)
if git tag | grep -q "$TAG_NAME@$VERSION"; then
if git tag | grep -qx "$TAG_NAME@$VERSION"; then
echo "Tag $TAG_NAME@$VERSION already exists, skipping release preparation"
else
echo "Tag $TAG_NAME@$VERSION does not exist, proceeding with release preparation"

View File

@@ -1,8 +1,7 @@
---
name: "cli: check and build"
on:
# pull_request_target:
pull_request:
pull_request_target:
paths:
- '.github/workflows/cli_checks.yaml'
- '.github/workflows/wf_check.yaml'
@@ -50,7 +49,7 @@ jobs:
with:
NAME: cli
PATH: cli
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
secrets:
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
@@ -65,7 +64,7 @@ jobs:
with:
NAME: cli
PATH: cli
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
VERSION: 0.0.0-dev # we use a fixed version here to avoid unnecessary rebuilds
DOCKER: true
secrets:
@@ -81,7 +80,7 @@ jobs:
with:
NAME: cli
PATH: cli
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
secrets:
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}

View File

@@ -63,7 +63,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: "Get artifacts"
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
path: ~/artifacts

View File

@@ -1,8 +1,7 @@
---
name: "codegen: check and build"
on:
# pull_request_target:
pull_request:
pull_request_target:
paths:
- '.github/workflows/wf_check.yaml'
- '.github/workflows/codegen_checks.yaml'
@@ -48,7 +47,7 @@ jobs:
with:
NAME: codegen
PATH: tools/codegen
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
secrets:
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
@@ -62,7 +61,7 @@ jobs:
with:
NAME: codegen
PATH: tools/codegen
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
VERSION: 0.0.0-dev # we use a fixed version here to avoid unnecessary rebuilds
DOCKER: false
secrets:

View File

@@ -1,7 +1,7 @@
---
name: "dashboard: check and build"
on:
pull_request:
pull_request_target:
paths:
- '.github/workflows/wf_build_artifacts.yaml'
- '.github/workflows/wf_check.yaml'
@@ -54,7 +54,7 @@ jobs:
- check-permissions
with:
NAME: dashboard
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
ENVIRONMENT: preview
secrets:
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
@@ -73,7 +73,7 @@ jobs:
with:
NAME: dashboard
PATH: dashboard
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
VERSION: 0.0.0-dev # we use a fixed version here to avoid unnecessary rebuilds
DOCKER: true
OS_MATRIX: '["blacksmith-2vcpu-ubuntu-2404"]'
@@ -91,7 +91,7 @@ jobs:
with:
NAME: dashboard
PATH: dashboard
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
secrets:
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
NIX_CACHE_PUB_KEY: ${{ secrets.NIX_CACHE_PUB_KEY }}
@@ -107,7 +107,7 @@ jobs:
with:
NAME: dashboard
PATH: dashboard
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
NHOST_TEST_DASHBOARD_URL: ${{ needs.deploy-vercel.outputs.preview-url }}
NHOST_TEST_PROJECT_NAME: ${{ vars.NHOST_TEST_PROJECT_NAME }}
NHOST_TEST_ORGANIZATION_NAME: ${{ vars.NHOST_TEST_ORGANIZATION_NAME }}

View File

@@ -148,7 +148,7 @@ jobs:
rm playwright-report.tar.gz
- name: Upload encrypted Playwright report
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
if: failure()
with:
name: encrypted-playwright-report-${{ github.run_id }}

View File

@@ -1,7 +1,7 @@
---
name: "docs: check and build"
on:
pull_request:
pull_request_target:
paths:
- '.github/workflows/wf_check.yaml'
- '.github/workflows/dashboard_checks.yaml'
@@ -62,7 +62,7 @@ jobs:
with:
NAME: docs
PATH: docs
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
secrets:
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
NIX_CACHE_PUB_KEY: ${{ secrets.NIX_CACHE_PUB_KEY }}

View File

@@ -1,8 +1,7 @@
---
name: "examples/demos: check and build"
on:
# pull_request_target:
pull_request:
pull_request_target:
paths:
- '.github/workflows/wf_check.yaml'
- '.github/workflows/examples_demos_checks.yaml'
@@ -64,7 +63,7 @@ jobs:
with:
NAME: demos
PATH: examples/demos
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
secrets:
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
@@ -78,7 +77,7 @@ jobs:
with:
NAME: demos
PATH: examples/demos
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
VERSION: 0.0.0-dev # we use a fixed version here to avoid unnecessary rebuilds
DOCKER: false
OS_MATRIX: '["blacksmith-2vcpu-ubuntu-2404"]'

View File

@@ -1,8 +1,7 @@
---
name: "examples/guides: check and build"
on:
# pull_request_target:
pull_request:
pull_request_target:
paths:
- '.github/workflows/wf_check.yaml'
- '.github/workflows/examples_guides_checks.yaml'
@@ -64,7 +63,7 @@ jobs:
with:
NAME: guides
PATH: examples/guides
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
secrets:
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
@@ -78,7 +77,7 @@ jobs:
with:
NAME: guides
PATH: examples/guides
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
VERSION: 0.0.0-dev # we use a fixed version here to avoid unnecessary rebuilds
DOCKER: false
OS_MATRIX: '["blacksmith-2vcpu-ubuntu-2404"]'

View File

@@ -1,8 +1,7 @@
---
name: "examples/tutorials: check and build"
on:
# pull_request_target:
pull_request:
pull_request_target:
paths:
- '.github/workflows/wf_check.yaml'
- '.github/workflows/examples_tutorials_checks.yaml'
@@ -64,7 +63,7 @@ jobs:
with:
NAME: tutorials
PATH: examples/tutorials
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
secrets:
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
@@ -78,7 +77,7 @@ jobs:
with:
NAME: tutorials
PATH: examples/tutorials
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
VERSION: 0.0.0-dev # we use a fixed version here to avoid unnecessary rebuilds
DOCKER: false
OS_MATRIX: '["blacksmith-2vcpu-ubuntu-2404"]'

View File

@@ -1,7 +1,7 @@
---
name: "gen: AI review"
on:
pull_request:
pull_request_target:
types: [opened, reopened, ready_for_review]
issue_comment:
jobs:

View File

@@ -1,8 +1,7 @@
---
name: "nhost-js: check and build"
on:
# pull_request_target:
pull_request:
pull_request_target:
paths:
- '.github/workflows/wf_check.yaml'
- '.github/workflows/nhost-js_checks.yaml'
@@ -65,7 +64,7 @@ jobs:
with:
NAME: nhost-js
PATH: packages/nhost-js
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
secrets:
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
@@ -79,7 +78,7 @@ jobs:
with:
NAME: nhost-js
PATH: packages/nhost-js
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
VERSION: 0.0.0-dev # we use a fixed version here to avoid unnecessary rebuilds
DOCKER: false
secrets:

View File

@@ -1,8 +1,7 @@
---
name: "nixops: check and build"
on:
# pull_request_target:
pull_request:
pull_request_target:
paths:
- '.github/workflows/wf_check.yaml'
- '.github/workflows/nixops_checks.yaml'
@@ -40,7 +39,7 @@ jobs:
with:
NAME: nixops
PATH: nixops
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
secrets:
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
@@ -54,7 +53,7 @@ jobs:
with:
NAME: nixops
PATH: nixops
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
VERSION: 0.0.0-dev # we use a fixed version here to avoid unnecessary rebuilds
DOCKER: true
secrets:

View File

@@ -1,8 +1,7 @@
---
name: "storage: check and build"
on:
# pull_request_target:
pull_request:
pull_request_target:
paths:
- '.github/workflows/storage_checks.yaml'
- '.github/workflows/wf_check.yaml'
@@ -18,6 +17,7 @@ on:
- '.golangci.yaml'
- 'go.mod'
- 'go.sum'
- 'internal/lib/**'
- 'vendor/**'
# storage
@@ -49,7 +49,7 @@ jobs:
with:
NAME: storage
PATH: services/storage
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
secrets:
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
@@ -64,7 +64,7 @@ jobs:
with:
NAME: storage
PATH: services/storage
GIT_REF: ${{ github.sha }}
GIT_REF: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
VERSION: 0.0.0-dev # we use a fixed version here to avoid unnecessary rebuilds
DOCKER: true
secrets:

View File

@@ -85,7 +85,7 @@ jobs:
zip -r result.zip result
- name: "Push artifact to artifact repository"
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: ${{ inputs.NAME }}-artifact-${{ steps.vars.outputs.ARCH }}-${{ steps.vars.outputs.VERSION }}
path: ${{ inputs.PATH }}/result.zip
@@ -100,7 +100,7 @@ jobs:
if: ${{ ( inputs.DOCKER ) }}
- name: "Push docker image to artifact repository"
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: ${{ inputs.NAME }}-docker-image-${{ steps.vars.outputs.ARCH }}-${{ steps.vars.outputs.VERSION }}
path: ${{ inputs.PATH }}/result

View File

@@ -44,7 +44,7 @@ jobs:
echo "VERSION=$(make get-version VER=${{ inputs.VERSION }})" >> $GITHUB_OUTPUT
- name: "Get artifacts"
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
path: ~/artifacts

View File

@@ -55,7 +55,7 @@ jobs:
echo "VERSION=$(make get-version VER=${{ inputs.VERSION }})" >> $GITHUB_OUTPUT
- name: "Get artifacts"
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
path: ~/artifacts

View File

@@ -16,6 +16,15 @@ Contributions are made to Nhost repos via Issues and Pull Requests (PRs). A few
- We work hard to make sure issues are handled on time, but it could take a while to investigate the root cause depending on the impact. A friendly ping in the comment thread to the submitter or a contributor can help draw attention if your issue is blocking.
- If you've never contributed before, see [the first-timer's guide](https://github.com/firstcontributions/first-contributions) for resources and tips on getting started.
### AI-Assisted Contributions
We have specific policies regarding AI-assisted contributions:
- **Issues**: Bug reports and feature requests that are clearly AI-generated will not be accepted and will be closed immediately. Please write your issues in your own words to ensure they are clear, specific, and contain the necessary context.
- **Pull Requests**: Contributions with the help of AI are permitted, but you are ultimately responsible for the quality of your submission and for ensuring it follows our contributing guidelines. The PR description must be written in your own words. Additionally, please remove any superfluous code comments introduced by AI tools before submitting. PRs that clearly violate this rule will be closed without further review.
In all cases, contributors must ensure their submissions are thoughtful, well-tested, and meet the project's quality standards.
### Issues
Issues should be used to report problems with Nhost, request a new feature, or discuss potential changes before a PR is created.

View File

@@ -2,6 +2,19 @@
All notable changes to this project will be documented in this file.
## [cli@1.34.4] - 2025-10-28
### 🐛 Bug Fixes
- *(cli)* Update NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL correctly (#3643)
## [cli@1.34.3] - 2025-10-27
### ⚙️ Miscellaneous Tasks
- *(cli)* Update schema (#3622)
- *(cli)* Bump nhost/dashboard to 2.40.0 (#3629)
## [cli@1.34.2] - 2025-10-20
### ⚙️ Miscellaneous Tasks

View File

@@ -56,7 +56,7 @@ func CommandCloud() *cli.Command {
&cli.StringFlag{ //nolint:exhaustruct
Name: flagDashboardVersion,
Usage: "Dashboard version to use",
Value: "nhost/dashboard:2.38.4",
Value: "nhost/dashboard:2.40.0",
Sources: cli.EnvVars("NHOST_DASHBOARD_VERSION"),
},
&cli.StringFlag{ //nolint:exhaustruct

View File

@@ -111,7 +111,7 @@ func CommandUp() *cli.Command { //nolint:funlen
&cli.StringFlag{ //nolint:exhaustruct
Name: flagDashboardVersion,
Usage: "Dashboard version to use",
Value: "nhost/dashboard:2.38.4",
Value: "nhost/dashboard:2.40.0",
Sources: cli.EnvVars("NHOST_DASHBOARD_VERSION"),
},
&cli.StringFlag{ //nolint:exhaustruct

View File

@@ -344,7 +344,7 @@ func dashboard(
subdomain, "hasura", httpPort, useTLS,
) + "/console",
"NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL": URL(
subdomain, "hasura", httpPort, useTLS),
subdomain, "hasura", httpPort, useTLS) + "/apis/migrate",
"NEXT_PUBLIC_NHOST_STORAGE_URL": URL(
subdomain, "storage", httpPort, useTLS) + "/v1",
},

View File

@@ -1,6 +1,6 @@
// Package auth provides primitives to interact with the openapi HTTP API.
//
// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version 2.4.1 DO NOT EDIT.
// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version 2.5.0 DO NOT EDIT.
package auth
import (

View File

@@ -1,6 +1,6 @@
// Package graphql provides primitives to interact with the openapi HTTP API.
//
// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version 2.4.1 DO NOT EDIT.
// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version 2.5.0 DO NOT EDIT.
package graphql
import (

View File

@@ -2823,6 +2823,7 @@ type Deployments struct {
CommitSha string `json:"commitSHA"`
CommitUserAvatarURL *string `json:"commitUserAvatarUrl,omitempty"`
CommitUserName *string `json:"commitUserName,omitempty"`
CreatedAt time.Time `json:"createdAt"`
DeploymentEndedAt *time.Time `json:"deploymentEndedAt,omitempty"`
// An array relationship
DeploymentLogs []*DeploymentLogs `json:"deploymentLogs"`
@@ -2865,6 +2866,7 @@ type DeploymentsBoolExp struct {
CommitSha *StringComparisonExp `json:"commitSHA,omitempty"`
CommitUserAvatarURL *StringComparisonExp `json:"commitUserAvatarUrl,omitempty"`
CommitUserName *StringComparisonExp `json:"commitUserName,omitempty"`
CreatedAt *TimestamptzComparisonExp `json:"createdAt,omitempty"`
DeploymentEndedAt *TimestamptzComparisonExp `json:"deploymentEndedAt,omitempty"`
DeploymentLogs *DeploymentLogsBoolExp `json:"deploymentLogs,omitempty"`
DeploymentStartedAt *TimestamptzComparisonExp `json:"deploymentStartedAt,omitempty"`
@@ -2899,6 +2901,7 @@ type DeploymentsMaxOrderBy struct {
CommitSha *OrderBy `json:"commitSHA,omitempty"`
CommitUserAvatarURL *OrderBy `json:"commitUserAvatarUrl,omitempty"`
CommitUserName *OrderBy `json:"commitUserName,omitempty"`
CreatedAt *OrderBy `json:"createdAt,omitempty"`
DeploymentEndedAt *OrderBy `json:"deploymentEndedAt,omitempty"`
DeploymentStartedAt *OrderBy `json:"deploymentStartedAt,omitempty"`
DeploymentStatus *OrderBy `json:"deploymentStatus,omitempty"`
@@ -2921,6 +2924,7 @@ type DeploymentsMinOrderBy struct {
CommitSha *OrderBy `json:"commitSHA,omitempty"`
CommitUserAvatarURL *OrderBy `json:"commitUserAvatarUrl,omitempty"`
CommitUserName *OrderBy `json:"commitUserName,omitempty"`
CreatedAt *OrderBy `json:"createdAt,omitempty"`
DeploymentEndedAt *OrderBy `json:"deploymentEndedAt,omitempty"`
DeploymentStartedAt *OrderBy `json:"deploymentStartedAt,omitempty"`
DeploymentStatus *OrderBy `json:"deploymentStatus,omitempty"`
@@ -2959,6 +2963,7 @@ type DeploymentsOrderBy struct {
CommitSha *OrderBy `json:"commitSHA,omitempty"`
CommitUserAvatarURL *OrderBy `json:"commitUserAvatarUrl,omitempty"`
CommitUserName *OrderBy `json:"commitUserName,omitempty"`
CreatedAt *OrderBy `json:"createdAt,omitempty"`
DeploymentEndedAt *OrderBy `json:"deploymentEndedAt,omitempty"`
DeploymentLogsAggregate *DeploymentLogsAggregateOrderBy `json:"deploymentLogs_aggregate,omitempty"`
DeploymentStartedAt *OrderBy `json:"deploymentStartedAt,omitempty"`
@@ -2990,6 +2995,7 @@ type DeploymentsStreamCursorValueInput struct {
CommitSha *string `json:"commitSHA,omitempty"`
CommitUserAvatarURL *string `json:"commitUserAvatarUrl,omitempty"`
CommitUserName *string `json:"commitUserName,omitempty"`
CreatedAt *time.Time `json:"createdAt,omitempty"`
DeploymentEndedAt *time.Time `json:"deploymentEndedAt,omitempty"`
DeploymentStartedAt *time.Time `json:"deploymentStartedAt,omitempty"`
DeploymentStatus *string `json:"deploymentStatus,omitempty"`
@@ -6983,6 +6989,8 @@ const (
// column name
DeploymentsSelectColumnCommitUserName DeploymentsSelectColumn = "commitUserName"
// column name
DeploymentsSelectColumnCreatedAt DeploymentsSelectColumn = "createdAt"
// column name
DeploymentsSelectColumnDeploymentEndedAt DeploymentsSelectColumn = "deploymentEndedAt"
// column name
DeploymentsSelectColumnDeploymentStartedAt DeploymentsSelectColumn = "deploymentStartedAt"
@@ -7016,6 +7024,7 @@ var AllDeploymentsSelectColumn = []DeploymentsSelectColumn{
DeploymentsSelectColumnCommitSha,
DeploymentsSelectColumnCommitUserAvatarURL,
DeploymentsSelectColumnCommitUserName,
DeploymentsSelectColumnCreatedAt,
DeploymentsSelectColumnDeploymentEndedAt,
DeploymentsSelectColumnDeploymentStartedAt,
DeploymentsSelectColumnDeploymentStatus,
@@ -7033,7 +7042,7 @@ var AllDeploymentsSelectColumn = []DeploymentsSelectColumn{
func (e DeploymentsSelectColumn) IsValid() bool {
switch e {
case DeploymentsSelectColumnAppID, DeploymentsSelectColumnCommitMessage, DeploymentsSelectColumnCommitSha, DeploymentsSelectColumnCommitUserAvatarURL, DeploymentsSelectColumnCommitUserName, DeploymentsSelectColumnDeploymentEndedAt, DeploymentsSelectColumnDeploymentStartedAt, DeploymentsSelectColumnDeploymentStatus, DeploymentsSelectColumnFunctionsEndedAt, DeploymentsSelectColumnFunctionsStartedAt, DeploymentsSelectColumnFunctionsStatus, DeploymentsSelectColumnID, DeploymentsSelectColumnMetadataEndedAt, DeploymentsSelectColumnMetadataStartedAt, DeploymentsSelectColumnMetadataStatus, DeploymentsSelectColumnMigrationsEndedAt, DeploymentsSelectColumnMigrationsStartedAt, DeploymentsSelectColumnMigrationsStatus:
case DeploymentsSelectColumnAppID, DeploymentsSelectColumnCommitMessage, DeploymentsSelectColumnCommitSha, DeploymentsSelectColumnCommitUserAvatarURL, DeploymentsSelectColumnCommitUserName, DeploymentsSelectColumnCreatedAt, DeploymentsSelectColumnDeploymentEndedAt, DeploymentsSelectColumnDeploymentStartedAt, DeploymentsSelectColumnDeploymentStatus, DeploymentsSelectColumnFunctionsEndedAt, DeploymentsSelectColumnFunctionsStartedAt, DeploymentsSelectColumnFunctionsStatus, DeploymentsSelectColumnID, DeploymentsSelectColumnMetadataEndedAt, DeploymentsSelectColumnMetadataStartedAt, DeploymentsSelectColumnMetadataStatus, DeploymentsSelectColumnMigrationsEndedAt, DeploymentsSelectColumnMigrationsStartedAt, DeploymentsSelectColumnMigrationsStatus:
return true
}
return false

View File

@@ -3,12 +3,13 @@ NEXT_PUBLIC_ENV=dev
NEXT_PUBLIC_NHOST_PLATFORM=false
# Environment Variables for Self Hosting and Local Development
NEXT_PUBLIC_NHOST_AUTH_URL=https://local.auth.nhost.local.run/v1
NEXT_PUBLIC_NHOST_AUTH_URL=https://local.auth.local.nhost.run/v1
NEXT_PUBLIC_NHOST_CONFIGSERVER_URL=https://local.dashboard.local.nhost.run/v1/configserver/graphql
NEXT_PUBLIC_NHOST_FUNCTIONS_URL=https://local.functions.local.nhost.run/v1
NEXT_PUBLIC_NHOST_GRAPHQL_URL=https://local.graphql.local.nhost.run/v1
NEXT_PUBLIC_NHOST_STORAGE_URL=https://local.storage.local.nhost.run/v1
NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL=https://local.hasura.local.nhost.run
NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL=https://local.hasura.local.nhost.run/v1/migrations
NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL=https://local.hasura.local.nhost.run/console
NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL=https://local.hasura.local.nhost.run/apis/migrate
NEXT_PUBLIC_NHOST_HASURA_API_URL=https://local.hasura.local.nhost.run
# Environment Variables when running the Nhost Dashboard against the Nhost Backend

View File

@@ -2,6 +2,17 @@
All notable changes to this project will be documented in this file.
## [@nhost/dashboard@2.40.0] - 2025-10-27
### 🚀 Features
- *(dashboard)* Allow configuring CSP header (#3627)
### ⚙️ Miscellaneous Tasks
- *(dashboard)* Various improvements to support ticket page (#3630)
## [@nhost/dashboard@2.39.0] - 2025-10-22
### 🚀 Features

View File

@@ -82,6 +82,15 @@ This will connect the Nhost Dashboard to your locally running Nhost backend.
| `NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL` | The URL of Hasura's Migrations service. When working locally, point it to the Migrations service started by the CLI. |
| `NEXT_PUBLIC_NHOST_HASURA_API_URL` | The URL of Hasura's Schema and Metadata API. When working locally, point it to the Schema and Metadata API started by the CLI. When self-hosting, point it to the self-hosted Schema and Metadata API. |
### Content Security Policy (CSP) Configuration
The dashboard supports build-time CSP configuration to enable self-hosted deployments on custom domains.
| Name | Description |
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `CSP_MODE` | Controls CSP behavior. Options: `nhost` (default, uses Nhost Cloud CSP), `disabled` (no CSP headers), `custom` (use custom CSP via `CSP_HEADER`). For self-hosted deployments on custom domains, set to `disabled` or `custom`. |
| `CSP_HEADER` | Custom Content Security Policy header value. Only used when `CSP_MODE=custom`. Should be a complete CSP string (e.g., `default-src 'self'; script-src 'self' 'unsafe-eval'; ...`). |
### Other Environment Variables
| Name | Description |

View File

@@ -4,21 +4,31 @@ const withBundleAnalyzer = require('@next/bundle-analyzer')({
});
const { version } = require('./package.json');
const cspHeader = `
default-src 'self' *.nhost.run wss://*.nhost.run nhost.run wss://nhost.run;
script-src 'self' 'unsafe-eval' cdn.segment.com js.stripe.com challenges.cloudflare.com googletagmanager.com;
connect-src 'self' *.nhost.run wss://*.nhost.run nhost.run wss://nhost.run discord.com api.segment.io api.segment.com cdn.segment.com nhost.zendesk.com;
style-src 'self' 'unsafe-inline';
img-src 'self' blob: data: github.com avatars.githubusercontent.com s.gravatar.com *.nhost.run nhost.run;
font-src 'self' data:;
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
frame-src 'self' js.stripe.com challenges.cloudflare.com;
block-all-mixed-content;
upgrade-insecure-requests;
`;
function getCspHeader() {
switch (process.env.CSP_MODE) {
case 'disabled':
return null;
case 'custom':
return process.env.CSP_HEADER || null;
case 'nhost':
default:
return [
"default-src 'self' *.nhost.run wss://*.nhost.run nhost.run wss://nhost.run",
"script-src 'self' 'unsafe-eval' cdn.segment.com js.stripe.com challenges.cloudflare.com googletagmanager.com",
"connect-src 'self' *.nhost.run wss://*.nhost.run nhost.run wss://nhost.run discord.com api.segment.io api.segment.com cdn.segment.com nhost.zendesk.com",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' blob: data: github.com avatars.githubusercontent.com s.gravatar.com *.nhost.run nhost.run",
"font-src 'self' data:",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
"frame-src 'self' js.stripe.com challenges.cloudflare.com",
"block-all-mixed-content",
"upgrade-insecure-requests",
].join('; ') + ';';
}
}
module.exports = withBundleAnalyzer({
reactStrictMode: false,
@@ -34,13 +44,19 @@ module.exports = withBundleAnalyzer({
dirs: ['src'],
},
async headers() {
const cspHeader = getCspHeader();
if (!cspHeader) {
return []; // No CSP headers
}
return [
{
source: '/(.*)',
source: '/:path*',
headers: [
{
key: 'Content-Security-Policy',
value: cspHeader.replace(/\s+/g, ' ').trim(),
value: cspHeader,
},
{
key: 'X-Frame-Options',

View File

@@ -166,6 +166,12 @@ rec {
'';
};
packageWithDisabledCSP = package.overrideAttrs (oldAttrs: {
configurePhase = oldAttrs.configurePhase + ''
export CSP_MODE=disabled
'';
});
dockerImage = pkgs.runCommand "image-as-dir" { } ''
${(nix2containerPkgs.nix2container.buildImage {
inherit name created;
@@ -175,7 +181,7 @@ rec {
copyToRoot = pkgs.buildEnv {
name = "image";
paths = [
package
packageWithDisabledCSP
(pkgs.writeTextFile {
name = "tmp-file";
text = ''

View File

@@ -127,14 +127,14 @@ export default function AuthenticatedLayout({
className="relative flex h-full flex-row overflow-hidden"
ref={setMainNavContainer}
>
{mainNavPinned && isMdOrLarger && <PinnedMainNav />}
{withMainNav && mainNavPinned && isMdOrLarger && <PinnedMainNav />}
<div
className={cn('relative flex h-full w-full flex-row bg-accent', {
'overflow-x-auto': mainNavPinned && isMdOrLarger && withMainNav,
})}
>
{(!mainNavPinned || !isMdOrLarger) && (
{withMainNav && (!mainNavPinned || !isMdOrLarger) && (
<div className="flex h-full w-6 justify-center">
<MainNav container={mainNavContainer} />
</div>

View File

@@ -176,7 +176,7 @@ export default function AppleProviderSettings() {
loading: formState.isSubmitting,
},
}}
docsLink="https://docs.nhost.io/products/auth/social/sign-in-apple"
docsLink="https://docs.nhost.io/products/auth/providers/sign-in-apple"
docsTitle="how to sign in users with Apple"
icon={
theme.palette.mode === 'dark'

View File

@@ -141,7 +141,7 @@ export default function DiscordProviderSettings() {
loading: formState.isSubmitting,
},
}}
docsLink="https://docs.nhost.io/products/auth/social/sign-in-discord"
docsLink="https://docs.nhost.io/products/auth/providers/sign-in-discord"
docsTitle="how to sign in users with Discord"
icon="/assets/brands/discord.svg"
switchId="enabled"

View File

@@ -142,7 +142,7 @@ export default function FacebookProviderSettings() {
loading: formState.isSubmitting,
},
}}
docsLink="https://docs.nhost.io/products/auth/social/sign-in-facebook"
docsLink="https://docs.nhost.io/products/auth/providers/sign-in-facebook"
docsTitle="how to sign in users with Facebook"
icon="/assets/brands/facebook.svg"
switchId="enabled"

View File

@@ -144,7 +144,7 @@ export default function GitHubProviderSettings() {
loading: formState.isSubmitting,
},
}}
docsLink="https://docs.nhost.io/products/auth/social/sign-in-github"
docsLink="https://docs.nhost.io/products/auth/providers/sign-in-github"
docsTitle="how to sign in users with GitHub"
icon={
theme.palette.mode === 'dark'

View File

@@ -162,7 +162,7 @@ export default function GoogleProviderSettings() {
loading: formState.isSubmitting,
},
}}
docsLink="https://docs.nhost.io/products/auth/social/sign-in-google"
docsLink="https://docs.nhost.io/products/auth/providers/sign-in-google"
docsTitle="how to sign in users with Google"
icon="/assets/brands/google.svg"
switchId="enabled"

View File

@@ -142,7 +142,7 @@ export default function LinkedInProviderSettings() {
loading: formState.isSubmitting,
},
}}
docsLink="https://docs.nhost.io/products/auth/social/sign-in-linkedin"
docsLink="https://docs.nhost.io/products/auth/providers/sign-in-linkedin"
docsTitle="how to sign in users with LinkedIn"
icon="/assets/brands/linkedin.svg"
switchId="enabled"

View File

@@ -142,7 +142,7 @@ export default function SpotifyProviderSettings() {
loading: formState.isSubmitting,
},
}}
docsLink="https://docs.nhost.io/products/auth/social/sign-in-spotify"
docsLink="https://docs.nhost.io/products/auth/providers/sign-in-spotify"
docsTitle="how to sign in users with Spotify"
icon="/assets/brands/spotify.svg"
switchId="enabled"

View File

@@ -144,7 +144,7 @@ export default function TwitchProviderSettings() {
loading: formState.isSubmitting,
},
}}
docsLink="https://docs.nhost.io/products/auth/social/sign-in-twitch"
docsLink="https://docs.nhost.io/products/auth/providers/sign-in-twitch"
docsTitle="how to sign in users with Twitch"
icon={
theme.palette.mode === 'dark'

View File

@@ -177,7 +177,7 @@ export default function WorkOsProviderSettings() {
loading: formState.isSubmitting,
},
}}
docsLink="https://docs.nhost.io/products/auth/social/sign-in-workos"
docsLink="https://docs.nhost.io/products/auth/providers/sign-in-workos"
docsTitle="how to sign in users with WorkOS"
icon="/assets/brands/workos.svg"
switchId="enabled"

View File

@@ -3,7 +3,7 @@ import { generateAppServiceUrl } from '@/features/orgs/projects/common/utils/gen
import { useDatabaseQuery } from '@/features/orgs/projects/database/dataGrid/hooks/useDatabaseQuery';
import { useProject } from '@/features/orgs/projects/hooks/useProject';
import { getToastStyleProps } from '@/utils/constants/settings';
import { getHasuraAdminSecret } from '@/utils/env';
import { getHasuraAdminSecret, getHasuraMigrationsApiUrl } from '@/utils/env';
import { parseIdentifiersFromSQL } from '@/utils/sql';
import { useRouter } from 'next/router';
import { useState } from 'react';
@@ -53,7 +53,10 @@ export default function useRunSQL(
isCascade: boolean,
) => {
try {
const migrationApiResponse = await fetch(`${appUrl}/apis/migrate`, {
const url = isPlatform
? `${appUrl}/apis/migrate`
: getHasuraMigrationsApiUrl();
const migrationApiResponse = await fetch(url, {
method: 'POST',
headers: { 'x-hasura-admin-secret': adminSecret },
body: JSON.stringify({

View File

@@ -12,7 +12,9 @@ function SupportPage() {
return (
<Box className="h-full overflow-auto pb-4">
<Box className="flex w-full justify-start border-b-1 px-4 py-3">
<Logo className="w-6 cursor-pointer" />
<Link href="https://app.nhost.io" rel="noopener noreferrer">
<Logo className="w-6" />
</Link>
</Box>
<div className="flex flex-col items-center justify-center">

View File

@@ -5,7 +5,6 @@ import { AuthenticatedLayout } from '@/components/layout/AuthenticatedLayout';
import { Box } from '@/components/ui/v2/Box';
import { Button } from '@/components/ui/v2/Button';
import { Divider } from '@/components/ui/v2/Divider';
import { EnvelopeIcon } from '@/components/ui/v2/icons/EnvelopeIcon';
import { Input, inputClasses } from '@/components/ui/v2/Input';
import { Option } from '@/components/ui/v2/Option';
import { Text } from '@/components/ui/v2/Text';
@@ -18,6 +17,7 @@ import {
} from '@/utils/__generated__/graphql';
import { yupResolver } from '@hookform/resolvers/yup';
import { styled } from '@mui/material';
import { Mail } from 'lucide-react';
import { type ReactElement } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import * as Yup from 'yup';
@@ -28,7 +28,7 @@ type Organization = Omit<
>;
const validationSchema = Yup.object({
organization: Yup.string().label('Organization'),
organization: Yup.string().label('Organization').required(),
project: Yup.string().label('Project').required(),
services: Yup.array()
.of(Yup.object({ label: Yup.string(), value: Yup.string() }))
@@ -167,7 +167,7 @@ function TicketPage() {
>
{organizations.map((organization) => (
<Option
key={organization.name}
key={organization.id}
value={organization.id}
label={organization.name}
>
@@ -237,6 +237,8 @@ function TicketPage() {
slotProps={{
root: { className: 'grid grid-flow-col gap-1 mb-4' },
}}
error={!!errors.priority}
helperText={errors.priority?.message}
renderValue={(option) => (
<span className="inline-grid grid-flow-col items-center gap-2">
{option?.label}
@@ -286,7 +288,6 @@ function TicketPage() {
label="Subject"
placeholder="Summary of the problem you are experiencing"
fullWidth
autoFocus
inputProps={{ min: 2, max: 128 }}
error={!!errors.subject}
helperText={errors.subject?.message}
@@ -306,16 +307,16 @@ function TicketPage() {
helperText={errors.description?.message}
/>
<Box className="ml-auto flex w-80 flex-col gap-4">
<Box className="ml-auto flex flex-col gap-4 lg:w-80">
<Text color="secondary" className="text-right text-sm">
We will contact you at <strong>{user?.email}</strong>
</Text>
<Button
variant="outlined"
className="hover:!bg-white hover:!bg-opacity-10 focus:ring-0"
className="text-base hover:!bg-white hover:!bg-opacity-10 focus:ring-0"
size="large"
type="submit"
startIcon={<EnvelopeIcon />}
startIcon={<Mail className="size-4" />}
disabled={isSubmitting}
loading={isSubmitting}
>
@@ -334,7 +335,7 @@ function TicketPage() {
TicketPage.getLayout = function getLayout(page: ReactElement) {
return (
<AuthenticatedLayout title="Help & Support | Nhost">
<AuthenticatedLayout title="Help & Support | Nhost" withMainNav={false}>
{page}
</AuthenticatedLayout>
);

View File

@@ -122,29 +122,37 @@
"group": "Sign In Methods",
"pages": [
{
"group": "Social Providers",
"group": "Providers",
"icon": "at",
"pages": [
"products/auth/social/sign-in-apple",
"products/auth/social/sign-in-azuread",
"products/auth/social/sign-in-discord",
"products/auth/social/sign-in-entraid",
"products/auth/social/sign-in-facebook",
"products/auth/social/sign-in-github",
"products/auth/social/sign-in-google",
"products/auth/social/sign-in-linkedin",
"products/auth/social/sign-in-spotify",
"products/auth/social/sign-in-twitch",
"products/auth/social/sign-in-workos"
"products/auth/providers/overview",
"products/auth/providers/tokens",
"products/auth/providers/connect",
"products/auth/providers/idtokens",
{
"group": "Configuration",
"icon": "gear",
"pages": [
"products/auth/providers/sign-in-apple",
"products/auth/providers/sign-in-azuread",
"products/auth/providers/sign-in-discord",
"products/auth/providers/sign-in-entraid",
"products/auth/providers/sign-in-facebook",
"products/auth/providers/sign-in-github",
"products/auth/providers/sign-in-google",
"products/auth/providers/sign-in-linkedin",
"products/auth/providers/sign-in-spotify",
"products/auth/providers/sign-in-twitch",
"products/auth/providers/sign-in-workos"
]
}
]
},
"/products/auth/social-connect",
"/products/auth/sign-in-email-password",
"/products/auth/sign-in-otp",
"/products/auth/sign-in-magic-link",
"/products/auth/sign-in-sms-otp",
"/products/auth/webauthn",
"/products/auth/idtokens"
"/products/auth/webauthn"
]
},
{
@@ -152,7 +160,6 @@
"icon": "diagram-project",
"pages": [
"/products/auth/workflows/email-password",
"/products/auth/workflows/oauth-providers",
"/products/auth/workflows/passwordless-email",
"/products/auth/workflows/passwordless-sms",
"/products/auth/workflows/webauthn",
@@ -352,6 +359,7 @@
"reference/auth/post-signin-pat",
"reference/auth/get-signin-provider-{provider}",
"reference/auth/get-signin-provider-{provider}-callback",
"reference/auth/get-signin-provider-{provider}-callback-tokens",
"reference/auth/post-signin-provider-{provider}-callback",
"reference/auth/post-signin-webauthn",
"reference/auth/post-signin-webauthn-verify",
@@ -360,6 +368,7 @@
"reference/auth/post-signup-webauthn",
"reference/auth/post-signup-webauthn-verify",
"reference/auth/post-token",
"reference/auth/post-token-provider-{provider}",
"reference/auth/post-token-verify",
"reference/auth/get-user",
"reference/auth/post-user-deanonymize",
@@ -704,6 +713,20 @@
}
]
},
"redirects": [
{
"source": "/products/auth/social/:slug*",
"destination": "/products/auth/providers/:slug*"
},
{
"source": "/products/auth/social-connect",
"destination": "products/auth/providers/connect"
},
{
"source": "/products/auth/idtokens",
"destination": "products/auth/providers/idtokens"
}
],
"logo": {
"light": "/images/logo/light.svg",
"dark": "/images/logo/dark.svg"

View File

@@ -62,6 +62,7 @@ function build_typedoc() {
function build_cli_docs() {
echo "⚒️⚒️⚒️ Building CLI documentation..."
cli docs > reference/cli/commands.mdx
cat reference/cli/commands.mdx
}
build_openapi

View File

Before

Width:  |  Height:  |  Size: 245 KiB

After

Width:  |  Height:  |  Size: 245 KiB

View File

@@ -19,7 +19,7 @@ Nhost Auth is a ready-to-use authentication service seamlessly integrated with t
</Card>
<Card title="Security Keys (WebAuthn)" icon="square-5" href="/products/auth/webauthn">
</Card>
<Card title="ID Tokens" icon="square-6" href="/products/auth/idtokens">
<Card title="ID Tokens" icon="square-6" href="/products/auth/providers/idtokens">
</Card>
<Card title="Elevated Permissions" icon="square-7" href="/products/auth/elevated-permissions">
</Card>
@@ -28,24 +28,24 @@ Nhost Auth is a ready-to-use authentication service seamlessly integrated with t
### OAuth Providers
<CardGroup cols={4}>
<Card title="Apple" icon="apple" href="/products/auth/social/sign-in-apple">
<Card title="Apple" icon="apple" href="/products/auth/providers/sign-in-apple">
</Card>
<Card title="Discord" icon="discord" href="/products/auth/social/sign-in-discord">
<Card title="Discord" icon="discord" href="/products/auth/providers/sign-in-discord">
</Card>
<Card title="Entra ID" icon="microsoft" href="/products/auth/social/sign-in-entraid">
<Card title="Entra ID" icon="microsoft" href="/products/auth/providers/sign-in-entraid">
</Card>
<Card title="Facebook" icon="facebook" href="/products/auth/social/sign-in-facebook">
<Card title="Facebook" icon="facebook" href="/products/auth/providers/sign-in-facebook">
</Card>
<Card title="GitHub" icon="github" href="/products/auth/social/sign-in-github">
<Card title="GitHub" icon="github" href="/products/auth/providers/sign-in-github">
</Card>
<Card title="Google" icon="google" href="/products/auth/social/sign-in-google">
<Card title="Google" icon="google" href="/products/auth/providers/sign-in-google">
</Card>
<Card title="Linkedin" icon="linkedin" href="/products/auth/social/sign-in-linkedin">
<Card title="Linkedin" icon="linkedin" href="/products/auth/providers/sign-in-linkedin">
</Card>
<Card title="Spotify" icon="spotify" href="/products/auth/social/sign-in-spotify">
<Card title="Spotify" icon="spotify" href="/products/auth/providers/sign-in-spotify">
</Card>
<Card title="Twitch" icon="twitch" href="/products/auth/social/sign-in-twitch">
<Card title="Twitch" icon="twitch" href="/products/auth/providers/sign-in-twitch">
</Card>
<Card title="WorkOS" icon="square-9" href="/products/auth/social/sign-in-workos">
<Card title="WorkOS" icon="square-9" href="/products/auth/providers/sign-in-workos">
</Card>
</CardGroup>

View File

@@ -0,0 +1,41 @@
---
title: Connecting existing users
sidebarTitle: Connecting existing users
description: Connect a provider to existing user accounts
icon: link
---
With the provider connect feature, users can link configured providers with their account, regardless of the initial sign-up method. It enables users to link different providers to their accounts, even if the email addresses do not match (e.g., linking a GitHub profile to an account registered with a different email). This feature offers flexibility, allowing users to streamline their login process by connecting multiple authentication methods.
To add a provider authentication method to an existing user you need to call the url `https://${subdomain}.auth.${region}.nhost.run/v1/signin/provider/${provider}?connect=${jwt}`. This is very easy to achieve with our SDK:
``` js
nhost.auth.connectProvider({
provider: 'github'
})
```
<Note>
Keep in mind that as we need a `JWT` the user needs to be logged in.
</Note>
## Viewing and Deleting Provider Authentication Mechanisms
If you want to allow your users to view and/or delete provider authentication mechanisms, you can provide the necessary permissions to the table `auth.user_providers` (i.e. `select` and/or `delete`) and then use the appropriate GraphQL query. For example, the following permissions should allow users to list their own providers:
![provider connect permissions](/images/auth/provider-connect-permissions.png)
Using the following GraphQL query:
``` js
const { error, data } = await nhost.graphql.request(
gql`
query getAuthUserProviders {
authUserProviders {
id
providerId
}
}
`,
)
```

View File

@@ -13,7 +13,9 @@ ID tokens serve as a secure proof that a user has already been authenticated by
## Usage
To use ID tokens, you need to configure supported identity providers (currently [apple](/products/auth/social/sign-in-apple) and [google](/products/auth/social/sign-in-google)) and make sure the `audience` is set correctly.
<Note>
To use ID tokens, you need to configure supported identity providers (currently [apple](/products/auth/providers/sign-in-apple) and [google](/products/auth/providers/sign-in-google)) and make sure the `audience` is set correctly.
</Note>
### Sign in
@@ -41,7 +43,7 @@ Once everything is configured you can use an ID token to authenticate users with
### Link Provider to existing user
Similarly to the [Social Connect](/products/auth/social-connect) feature, you can link an identity provider to an existing user:
Similarly to the [Provider Connect](/products/auth/providers/connect) feature, you can link an identity provider to an existing user:
<Tabs>
<Tab title="javascript">
@@ -69,10 +71,4 @@ Below you can find some examples on how to extract an ID Token from various iden
### React Native
#### Apple
For an example on how to authenticate using "Sign in with Apple" on iOS using React Native you can refer to our [sample component](https://github.com/nhost/nhost/blob/main/examples/react_native/src/components/SignInWithAppleButton.tsx).
#### Google
For an example on how to authenticate using "Sign in with Google" on Android using React Native you can refer to our [sample component](https://github.com/nhost/nhost/blob/main/examples/react_native/src/components/SignInWithGoogleButton.tsx).
Please, refer to our [React Native Tutorial](/getting-started/tutorials/reactnative/1-introduction) on how to implement Sign in with Apple and Google Sign-In in your React Native app.

View File

@@ -0,0 +1,61 @@
---
title: Overview
sidebarTitle: Overview
description: Learn how OAuth2 authentication works with Nhost
icon: at
---
OAuth2 providers allow users to sign in to your Nhost application using their existing accounts from popular services like Google, GitHub, Apple, and many others. This eliminates the need for users to create and remember new credentials while providing a secure authentication method.
## How OAuth2 Authentication Works
When a user authenticates with an OAuth2 provider through Nhost, the following workflow occurs:
```mermaid
sequenceDiagram
autonumber
actor U as User
participant A as Auth
participant P as Oauth Provider
participant F as Frontend
U->>+A: HTTP GET /signin/provider/{provider}
A->>+P: Provider's authentication
deactivate A
P->>-A: HTTP GET /signin/provider/{provider}/callback
activate A
opt No user found
A->>A: Create user
end
A->>A: Flag user email as verified
A->>+F: HTTP redirect with refresh token
deactivate A
F->>-U: HTTP OK response
opt
U->>+A: HTTP POST /token
A->>-U: HTTP OK response
Note left of A: Refresh token + access token
end
```
<Snippet file="workflows/oauth-providers.mdx" />
### The Authentication Flow
1. **Initiation**: The user starts the authentication process by making a request to `/signin/provider/{provider}` (e.g., `/signin/provider/google`)
2. **Provider Authentication**: Auth redirects the user to the OAuth2 provider's authentication page where they log in and grant permissions
3. **Callback**: After successful authentication, the provider redirects back to Auth at `/signin/provider/{provider}/callback` with an authorization code
4. **User Management**: Auth processes the callback:
- If this is a new user, a user account is automatically created
- The user's email is flagged as verified (since the OAuth2 provider has already verified it)
5. **Token Issuance**: The user is redirected back to your frontend application with a refresh token
## Benefits of OAuth2 Authentication
- **Improved User Experience**: Users can sign in with accounts they already have
- **Enhanced Security**: No need to manage passwords; authentication is handled by established providers
- **Verified Emails**: Email addresses are automatically verified through the OAuth2 provider
- **Reduced Registration Friction**: Faster onboarding with one-click sign-in

View File

@@ -1,7 +1,7 @@
---
title: Sign In with WorkOS
description: Follow this guide to sign in users with WorkOS.
icon: workos
icon: W
---

View File

@@ -0,0 +1,137 @@
---
title: OAuth Provider Access Tokens
sidebarTitle: Tokens
description: Access and refresh OAuth provider tokens to interact with external APIs
icon: key
---
When users authenticate with OAuth providers through Nhost, you can access the provider's access and refresh tokens. This enables your application to make API calls directly to external services (like Google Drive, GitHub repositories, or Spotify playlists) on behalf of your users.
## How Provider Token Management Works
After a user completes OAuth authentication, you can retrieve and manage the provider's tokens to interact with their external accounts:
```mermaid
sequenceDiagram
autonumber
actor U as User
participant F as Frontend
participant A as Auth
participant P as OAuth Provider
Note over U,P: Initial OAuth Sign-In
U->>+F: Complete OAuth sign-in
F->>+A: GET /signin/provider/{provider}/callback/tokens
Note right of A: Bearer token required
A->>A: Retrieve stored provider session
A->>-F: Provider tokens (access, refresh, expiresIn)
F->>F: Store tokens securely
deactivate F
Note over U,P: Using Provider Access Token
U->>+F: Request action requiring provider API
F->>+P: API call with provider access token
P->>-F: API response
F->>-U: Result
Note over U,P: Refreshing Expired Token
F->>F: Detect token expiration
F->>+A: POST /token/provider/{provider}
Note right of A: Include refresh token
A->>+P: Request new access token
P->>-A: New access token (+ optional new refresh token)
A->>-F: Updated tokens
F->>F: Update stored tokens
```
### The Token Flow
1. **OAuth Completion**: After the user successfully authenticates with an OAuth provider, they are redirected back to your application
2. **Token Retrieval**: Your frontend immediately calls `/signin/provider/{provider}/callback/tokens` with the user's Nhost authentication token to retrieve the provider's tokens
3. **Secure Storage**: The provider's access token, refresh token, and expiration time are stored securely in your application
4. **Provider API Calls**: Your application uses the provider's access token to make API calls to the external service on behalf of the user
5. **Token Refresh**: When the access token expires, your application uses the refresh token to obtain a new access token without requiring the user to re-authenticate
## Retrieving Provider Tokens
After a successful OAuth sign-in, retrieve the provider's session to access their tokens.
```javascript
import { createClient } from '@nhost/nhost-js'
const nhost = createClient({
subdomain: 'myapp',
region: 'us-east-1'
})
// Immediately after OAuth callback completes
const resp = await nhost.auth.getProviderTokens('google')
// Store tokens securely
localStorage.setItem('google_access_token', resp.body.accessToken)
if (resp.body.refreshToken) {
localStorage.setItem('google_refresh_token', resp.body.refreshToken)
}
// Use provider token to call Google APIs
const userInfo = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
headers: {
'Authorization': `Bearer ${resp.body.accessToken}`
}
})
```
**Important Notes:**
- This method must be called **immediately** after the OAuth callback completes
- The provider session is stored temporarily and is cleared during this call
- Subsequent calls will fail without re-authenticating with the provider
- Requires the user to be authenticated with Nhost
## Refreshing Provider Tokens
Provider access tokens typically expire after a short period (often 1 hour). Use the refresh token to obtain a new access token without user interaction.
```javascript
import { createClient } from '@nhost/nhost-js'
const nhost = createClient({
subdomain: 'myapp',
region: 'us-east-1'
})
// Retrieve stored refresh token
const storedRefreshToken = localStorage.getItem('google_refresh_token')
// Refresh the provider tokens
const resp = await nhost.auth.refreshProviderToken('google', {
refreshToken: storedRefreshToken
})
// Update stored tokens
localStorage.setItem('google_access_token', resp.body.accessToken)
if (resp.body.refreshToken) {
// Some providers rotate refresh tokens
localStorage.setItem('google_refresh_token', resp.body.refreshToken)
}
```
**Important Notes:**
- Some providers (like Google) rotate refresh tokens, returning a new one in the response
- Always update your stored refresh token with the new value if provided
- Token refresh can fail if the user revokes access or the refresh token expires
- Handle these cases by prompting the user to re-authenticate
## Best Practices
**Immediate Retrieval** - Call the tokens endpoint immediately after OAuth callback to prevent session loss
**Secure Storage** - Store provider tokens in secure storage appropriate for your platform (encrypted storage for mobile, httpOnly cookies or secure localStorage for web)
**Proactive Refresh** - Use the `expiresIn` value to refresh tokens before they expire, preventing API call failures
**Handle Rotation** - Always update your stored refresh token when a new one is returned, as some providers invalidate old refresh tokens
**Graceful Errors** - Handle token refresh failures by prompting users to re-authenticate, as they may have revoked access through the provider's settings
**Token Isolation** - Store tokens per provider and per user to support multiple OAuth connections

View File

@@ -1,43 +0,0 @@
---
title: Social Provider Connect
sidebarTitle: Social Provider Connect
description: Add social sign in mechanism to existing users
icon: link
---
With the social provider connect feature, users can link their social authentication method to their account, regardless of the initial sign-up method. It enables users to link different social authentication providers to their accounts, even if the email addresses do not match (e.g., linking a GitHub profile to an account registered with a different email). This feature offers flexibility, allowing users to streamline their login process by connecting multiple authentication methods.
To add a social authentication method to an existing user you need to call the url `https://${subdomain}.auth.${region}.nhost.run/v1/signin/provider/${provider}?connect=${jwt}`. This is very easy to achieve with our SDK:
``` js
nhost.auth.connectProvider({
provider: 'github'
})
```
In addition, hooks for react, vue and other frameworks may be provided. Check our [reference](/reference/overview#client-libraries) documentation for more details.
<Note>
Keep in mind that as we need a `JWT` the user needs to be logged in.
</Note>
## Viewing and Deleting Social Provider Authentication Mechanisms
If you want to allow your users to view and/or delete social provider authentication mechanisms, you can provide the necessary permissions to the table `auth.user_providers` (i.e. `select` and/or `delete`) and then use the appropriate GraphQL query. For example, the following permissions should allow users to list their own social providers:
![social connect permissions](/images/auth/social-connect-permissions.png)
Using the following GraphQL query:
``` js
const { error, data } = await nhost.graphql.request(
gql`
query getAuthUserProviders {
authUserProviders {
id
providerId
}
}
`,
)
```

View File

@@ -1,25 +0,0 @@
```mermaid
sequenceDiagram
autonumber
actor U as User
participant A as Hasura Auth
participant P as Oauth Provider
participant F as Frontend
U->>+A: HTTP GET /signin/provider/{provider}
A->>+P: Provider's authentication
deactivate A
P->>-A: HTTP GET /signin/provider/{provider}/callback
activate A
opt No user found
A->>A: Create user
end
A->>A: Flag user email as verified
A->>+F: HTTP redirect with refresh token
deactivate A
F->>-U: HTTP OK response
opt
U->>+A: HTTP POST /token
A->>-U: HTTP OK response
Note left of A: Refresh token + access token
end
```

View File

@@ -592,6 +592,12 @@ paths:
If set, this means that the user is already authenticated and wants to link their account. This needs to be a valid JWT access token.
schema:
type: string
- name: state
in: query
required: false
description: Opaque state value to be returned by the provider
schema:
type: string
responses:
"302":
@@ -738,6 +744,31 @@ paths:
$ref: "#/components/schemas/ErrorResponse"
description: "An error occurred while processing the request"
/signin/provider/{provider}/callback/tokens:
get:
summary: Retrieve OAuth2 provider tokens from callback
description: After successful OAuth2 authentication, retrieve the provider session containing access token, refresh token, and expiration information for the specified provider. To ensure the data isn't stale this endpoint must be called immediately after the OAuth callback to obtain the tokens. The session is cleared from the database during this call, so subsequent calls will fail without going through the sign-in flow again. It is the user's responsibility to store the session safely (e.g., in browser local storage).
operationId: getProviderTokens
tags:
- authentication
security:
- BearerAuth: []
parameters:
- $ref: "#/components/parameters/SignInProvider"
responses:
"200":
description: Successfully retrieved provider session
content:
application/json:
schema:
$ref: "#/components/schemas/ProviderSession"
default:
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
description: "An error occurred while processing the request"
/signin/webauthn:
post:
summary: Sign in with Webauthn
@@ -954,6 +985,38 @@ paths:
$ref: "#/components/schemas/ErrorResponse"
description: "An error occurred while processing the request"
/token/provider/{provider}:
post:
summary: Refresh OAuth2 provider tokens
description: Refresh the OAuth2 provider access token using a valid refresh token. Returns a new provider session with updated access token, refresh token (if rotated by provider), and expiration information. This endpoint allows maintaining long-lived access to provider APIs without requiring the user to re-authenticate.
operationId: refreshProviderToken
tags:
- authentication
security:
- BearerAuth: []
parameters:
- $ref: "#/components/parameters/SignInProvider"
requestBody:
description: Provider refresh token to exchange for a new access token
content:
application/json:
schema:
$ref: "#/components/schemas/RefreshProviderTokenRequest"
required: true
responses:
"200":
description: Successfully refreshed provider tokens
content:
application/json:
schema:
$ref: "#/components/schemas/ProviderSession"
default:
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
description: "An error occurred while processing the request"
/token/verify:
post:
summary: Verify JWT token
@@ -1814,6 +1877,46 @@ components:
required:
- challenge
ProviderSession:
type: object
description: "OAuth2 provider session containing access and refresh tokens"
additionalProperties: false
properties:
accessToken:
type: string
description: "OAuth2 provider access token for API calls"
example: "ya29.a0AfH6SMBx..."
expiresIn:
type: integer
description: "Number of seconds until the access token expires"
example: 3599
expiresAt:
type: string
format: date-time
description: "Timestamp when the access token expires"
example: "2024-12-31T23:59:59Z"
refreshToken:
type: string
nullable: true
description: "OAuth2 provider refresh token for obtaining new access tokens (if provided by the provider)"
example: "1//0gK8..."
required:
- accessToken
- expiresIn
- expiresAt
RefreshProviderTokenRequest:
type: object
description: "Request to refresh OAuth2 provider tokens"
additionalProperties: false
properties:
refreshToken:
type: string
description: "OAuth2 provider refresh token obtained from previous authentication"
example: "1//0gK8..."
required:
- refreshToken
RefreshTokenRequest:
type: object
description: "Request to refresh an access token"
@@ -2512,7 +2615,6 @@ components:
- facebook
- windowslive
- twitter
deprecated: true
TicketQuery:
in: query

View File

@@ -0,0 +1,5 @@
---
title: "getProviderTokens"
openapi: get /signin/provider/{provider}/callback/tokens
sidebarTitle: /signin/provider/{provider}/callback/tokens
---

View File

@@ -0,0 +1,5 @@
---
title: "refreshProviderToken"
openapi: post /token/provider/{provider}
sidebarTitle: /token/provider/{provider}
---

View File

@@ -254,7 +254,7 @@ Start local development environment
**--ca-certificates**="": Mounts and everrides path to CA certificates in the containers
**--dashboard-version**="": Dashboard version to use (default: nhost/dashboard:2.38.4)
**--dashboard-version**="": Dashboard version to use (default: nhost/dashboard:2.40.0)
**--disable-tls**: Disable TLS
@@ -284,7 +284,7 @@ Start local development environment connected to an Nhost Cloud project (BETA)
**--ca-certificates**="": Mounts and everrides path to CA certificates in the containers
**--dashboard-version**="": Dashboard version to use (default: nhost/dashboard:2.38.4)
**--dashboard-version**="": Dashboard version to use (default: nhost/dashboard:2.40.0)
**--disable-tls**: Disable TLS

View File

@@ -468,6 +468,30 @@ This method may return different T based on the response code:
`Promise`&lt;[`FetchResponse`](./fetch#fetchresponse)&lt;[`JWKSet`](#jwkset)&gt;&gt;
#### getProviderTokens()
```ts
getProviderTokens(provider: SignInProvider, options?: RequestInit): Promise<FetchResponse<ProviderSession>>;
```
Summary: Retrieve OAuth2 provider tokens from callback
After successful OAuth2 authentication, retrieve the provider session containing access token, refresh token, and expiration information for the specified provider. To ensure the data isn't stale this endpoint must be called immediately after the OAuth callback to obtain the tokens. The session is cleared from the database during this call, so subsequent calls will fail without going through the sign-in flow again. It is the user's responsibility to store the session safely (e.g., in browser local storage).
This method may return different T based on the response code:
- 200: ProviderSession
##### Parameters
| Parameter | Type |
| ---------- | ----------------------------------- |
| `provider` | [`SignInProvider`](#signinprovider) |
| `options?` | `RequestInit` |
##### Returns
`Promise`&lt;[`FetchResponse`](./fetch#fetchresponse)&lt;[`ProviderSession`](#providersession)&gt;&gt;
#### getUser()
```ts
@@ -602,6 +626,34 @@ Add a middleware function to the fetch chain
`void`
#### refreshProviderToken()
```ts
refreshProviderToken(
provider: SignInProvider,
body: RefreshProviderTokenRequest,
options?: RequestInit): Promise<FetchResponse<ProviderSession>>;
```
Summary: Refresh OAuth2 provider tokens
Refresh the OAuth2 provider access token using a valid refresh token. Returns a new provider session with updated access token, refresh token (if rotated by provider), and expiration information. This endpoint allows maintaining long-lived access to provider APIs without requiring the user to re-authenticate.
This method may return different T based on the response code:
- 200: ProviderSession
##### Parameters
| Parameter | Type |
| ---------- | ------------------------------------------------------------- |
| `provider` | [`SignInProvider`](#signinprovider) |
| `body` | [`RefreshProviderTokenRequest`](#refreshprovidertokenrequest) |
| `options?` | `RequestInit` |
##### Returns
`Promise`&lt;[`FetchResponse`](./fetch#fetchresponse)&lt;[`ProviderSession`](#providersession)&gt;&gt;
#### refreshToken()
```ts
@@ -1605,6 +1657,54 @@ Format - uri
---
## ProviderSession
OAuth2 provider session containing access and refresh tokens
### Properties
#### accessToken
```ts
accessToken: string
```
(`string`) - OAuth2 provider access token for API calls
- Example - `"ya29.a0AfH6SMBx..."`
#### expiresAt
```ts
expiresAt: string
```
(`string`) - Timestamp when the access token expires
- Example - `"2024-12-31T23:59:59Z"`
- Format - date-time
#### expiresIn
```ts
expiresIn: number
```
(`number`) - Number of seconds until the access token expires
- Example - `3599`
#### refreshToken?
```ts
optional refreshToken: string;
```
OAuth2 provider refresh token for obtaining new access tokens (if provided by the provider)
Example - `"1//0gK8..."`
---
## PublicKeyCredentialCreationOptions
### Properties
@@ -1795,6 +1895,24 @@ A requirement for user verification for the operation
---
## RefreshProviderTokenRequest
Request to refresh OAuth2 provider tokens
### Properties
#### refreshToken
```ts
refreshToken: string
```
(`string`) - OAuth2 provider refresh token obtained from previous authentication
- Example - `"1//0gK8..."`
---
## RefreshTokenRequest
Request to refresh an access token
@@ -2295,6 +2413,14 @@ optional redirectTo: string;
URI to redirect to
#### state?
```ts
optional state: string;
```
Opaque state value to be returned by the provider
---
## SignInWebauthnRequest

View File

@@ -454,7 +454,7 @@ Example - `"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."`
##### Inherited from
[`Session`](./auth#session).[`accessToken`](./auth#accesstoken)
[`Session`](./auth#session).[`accessToken`](./auth#accesstoken-1)
#### accessTokenExpiresIn
@@ -490,7 +490,7 @@ Pattern - \b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b
##### Inherited from
[`Session`](./auth#session).[`refreshToken`](./auth#refreshtoken-3)
[`Session`](./auth#session).[`refreshToken`](./auth#refreshtoken-5)
#### refreshTokenId

18
flake.lock generated
View File

@@ -20,11 +20,11 @@
},
"nix-filter": {
"locked": {
"lastModified": 1731533336,
"narHash": "sha256-oRam5PS1vcrr5UPgALW0eo1m/5/pls27Z/pabHNy2Ms=",
"lastModified": 1757882181,
"narHash": "sha256-+cCxYIh2UNalTz364p+QYmWHs0P+6wDhiWR4jDIKQIU=",
"owner": "numtide",
"repo": "nix-filter",
"rev": "f7653272fd234696ae94229839a99b73c9ab7de0",
"rev": "59c44d1909c72441144b93cf0f054be7fe764de5",
"type": "github"
},
"original": {
@@ -40,11 +40,11 @@
]
},
"locked": {
"lastModified": 1752002763,
"narHash": "sha256-JYAkdZvpdSx9GUoHPArctYMypSONob4DYKRkOubUWtY=",
"lastModified": 1761716996,
"narHash": "sha256-vdOuy2pid2/DasUgb08lDOswdPJkN5qjXfBYItVy/R4=",
"owner": "nlewo",
"repo": "nix2container",
"rev": "4f2437f6a1844b843b380d483087ae6d461240ee",
"rev": "e5496ab66e9de9e3f67dc06f692dfbc471b6316e",
"type": "github"
},
"original": {
@@ -55,11 +55,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1753399495,
"narHash": "sha256-7XG/QBqhrYOyA2houjRTL2NMa7IKZZ/somBqr+Q/6Wo=",
"lastModified": 1761656231,
"narHash": "sha256-EiED5k6gXTWoAIS8yQqi5mAX6ojnzpHwAQTS3ykeYMg=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "0d00f23f023b7215b3f1035adb5247c8ec180dbc",
"rev": "e99366c665bdd53b7b500ccdc5226675cfc51f45",
"type": "github"
},
"original": {

View File

@@ -119,6 +119,7 @@
gofumpt
golangci-lint
gqlgenc
oapi-codegen
# internal packages
self.packages.${system}.codegen

4
go.mod
View File

@@ -1,6 +1,6 @@
module github.com/nhost/nhost
go 1.24.2
go 1.25.3
require (
github.com/99designs/gqlgen v0.17.80
@@ -16,7 +16,6 @@ require (
github.com/davidbyttow/govips/v2 v2.16.0
github.com/gabriel-vasile/mimetype v1.4.8
github.com/getkin/kin-openapi v0.133.0
github.com/gin-contrib/cors v1.7.3
github.com/gin-gonic/gin v1.11.0
github.com/go-git/go-git/v5 v5.16.2
github.com/go-webauthn/webauthn v0.12.2
@@ -30,7 +29,6 @@ require (
github.com/lmittmann/tint v1.0.7
github.com/mark3labs/mcp-go v0.41.1
github.com/nhost/be v0.0.0-20251021065906-8abc7d8dfa48
github.com/oapi-codegen/gin-middleware v1.0.2
github.com/oapi-codegen/runtime v1.1.1
github.com/pb33f/libopenapi v0.21.12
github.com/pelletier/go-toml/v2 v2.2.4

12
go.sum
View File

@@ -162,8 +162,6 @@ github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3G
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
github.com/gin-contrib/cors v1.7.3 h1:hV+a5xp8hwJoTw7OY+a70FsL8JkVVFTXw9EcfrYUdns=
github.com/gin-contrib/cors v1.7.3/go.mod h1:M3bcKZhxzsvI+rlRSkkxHyljJt1ESd93COUvemZ79j4=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
@@ -338,19 +336,9 @@ github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
github.com/nhost/be v0.0.0-20250929153845-6db3e5249d33 h1:BNFN3Mw4zY6LEmVc7RXkHSVvHtovDSm7Oesb7IUt27o=
github.com/nhost/be v0.0.0-20250929153845-6db3e5249d33/go.mod h1:feVvqP3dft8hWbp9zNZExdGKbFEYv8aLYohfyAeINNQ=
github.com/nhost/be v0.0.0-20251015125528-facecfb60cea h1:QQMCKMdkOkVN5PlZB/iiBSdQL6u84JIVppWf7hC5OoU=
github.com/nhost/be v0.0.0-20251015125528-facecfb60cea/go.mod h1:feVvqP3dft8hWbp9zNZExdGKbFEYv8aLYohfyAeINNQ=
github.com/nhost/be v0.0.0-20251020104454-acc8934d2c11 h1:5uPnBt2gjVkRpjUEJ8uS/q+FpvpTQJ+CN6zLCBYcfPA=
github.com/nhost/be v0.0.0-20251020104454-acc8934d2c11/go.mod h1:feVvqP3dft8hWbp9zNZExdGKbFEYv8aLYohfyAeINNQ=
github.com/nhost/be v0.0.0-20251020115144-85e0542ecec0 h1:MRWnaC1Aoir6JPr4v4C2TAVG5KBIgsOdWiDeII5HQYg=
github.com/nhost/be v0.0.0-20251020115144-85e0542ecec0/go.mod h1:feVvqP3dft8hWbp9zNZExdGKbFEYv8aLYohfyAeINNQ=
github.com/nhost/be v0.0.0-20251021065906-8abc7d8dfa48 h1:+Oh4Rbr1psWlBaQTakoBYFNB8jBioiXuimNMaNPLTHk=
github.com/nhost/be v0.0.0-20251021065906-8abc7d8dfa48/go.mod h1:feVvqP3dft8hWbp9zNZExdGKbFEYv8aLYohfyAeINNQ=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/oapi-codegen/gin-middleware v1.0.2 h1:/H99UzvHQAUxXK8pzdcGAZgjCVeXdFDAUUWaJT0k0eI=
github.com/oapi-codegen/gin-middleware v1.0.2/go.mod h1:2HJDQjH8jzK2/k/VKcWl+/T41H7ai2bKa6dN3AA2GpA=
github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro=
github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=

View File

@@ -0,0 +1,13 @@
package oapi
import "fmt"
type AuthenticatorError struct {
Scheme string
Code string
Message string
}
func (e *AuthenticatorError) Error() string {
return fmt.Sprintf("security error [%s]: %s", e.Code, e.Message)
}

View File

@@ -0,0 +1,10 @@
//go:generate oapi-codegen -config server.cfg.yaml openapi.yaml
//go:generate oapi-codegen -config types.cfg.yaml openapi.yaml
package api
import (
_ "embed"
)
//go:embed openapi.yaml
var OpenAPISchema []byte

View File

@@ -0,0 +1,200 @@
openapi: "3.0.0"
paths:
/signin/email-password:
post:
summary: Sign in with email and password
description: Authenticate a user with their email and password. Returns a session object or MFA challenge if two-factor authentication is enabled.
operationId: signInEmailPassword
requestBody:
description: User credentials for email and password authentication
content:
application/json:
schema:
$ref: "#/components/schemas/SignInEmailPasswordRequest"
required: true
responses:
"200":
content:
application/json:
schema:
$ref: "#/components/schemas/SignInEmailPasswordResponse"
description: "Authentication successful. If MFA is enabled, a challenge will be returned instead of a session."
default:
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
description: "An error occurred while processing the request"
/user/email/change:
post:
summary: Change user email
description: Request to change the authenticated user's email address. A verification email will be sent to the new address to confirm the change. Requires elevated permissions.
operationId: changeUserEmail
tags:
- user
security:
- BearerAuthElevated: []
requestBody:
description: New email address and optional redirect URL for email change
content:
application/json:
schema:
$ref: "#/components/schemas/UserEmailChangeRequest"
required: true
responses:
"200":
description: >-
Email change requested. An email with a verification link has been sent to the new address
content:
application/json:
schema:
$ref: "#/components/schemas/OKResponse"
default:
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
description: "An error occurred while processing the request"
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
description: "Bearer authentication with JWT access token. Used to authenticate requests to protected endpoints."
BearerAuthElevated:
type: http
scheme: bearer
description: "Bearer authentication that requires elevated permissions. Used for sensitive operations that may require additional security measures such as recent authentication. For details see https://docs.nhost.io/products/auth/elevated-permissions"
schemas:
SignInEmailPasswordRequest:
type: object
description: "Request to authenticate using email and password"
additionalProperties: false
properties:
email:
description: "User's email address"
example: "john.smith@nhost.io"
format: email
type: string
password:
description: "User's password"
example: "Str0ngPassw#ord-94|%"
minLength: 3
maxLength: 50
type: string
required:
- email
- password
SignInEmailPasswordResponse:
type: object
description: "Response for email-password authentication that may include a session or MFA challenge"
additionalProperties: false
properties:
session:
$ref: "#/components/schemas/Session"
Session:
type: object
description: "User authentication session containing tokens and user information"
additionalProperties: false
properties:
accessToken:
type: string
description: "JWT token for authenticating API requests"
example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
accessTokenExpiresIn:
type: integer
format: int64
description: "Expiration time of the access token in seconds"
example: 900
refreshTokenId:
description: "Identifier for the refresh token"
example: "2c35b6f3-c4b9-48e3-978a-d4d0f1d42e24"
pattern: \b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b
type: string
refreshToken:
description: "Token used to refresh the access token"
example: "2c35b6f3-c4b9-48e3-978a-d4d0f1d42e24"
pattern: \b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b
type: string
required:
- accessToken
- accessTokenExpiresIn
- refreshToken
- refreshTokenId
UserEmailChangeRequest:
type: object
additionalProperties: false
properties:
newEmail:
description: A valid email
example: john.smith@nhost.io
format: email
type: string
required:
- newEmail
OKResponse:
type: string
additionalProperties: false
enum:
- OK
ErrorResponse:
type: object
description: "Standardized error response"
additionalProperties: false
properties:
status:
description: "HTTP status error code"
type: integer
example: 400
message:
description: "Human-friendly error message"
type: string
example: "Invalid email format"
error:
description: "Error code identifying the specific application error"
type: string
enum:
- default-role-must-be-in-allowed-roles
- disabled-endpoint
- disabled-user
- email-already-in-use
- email-already-verified
- forbidden-anonymous
- internal-server-error
- invalid-email-password
- invalid-request
- locale-not-allowed
- password-too-short
- password-in-hibp-database
- redirectTo-not-allowed
- role-not-allowed
- signup-disabled
- unverified-user
- user-not-anonymous
- invalid-pat
- invalid-refresh-token
- invalid-ticket
- disabled-mfa-totp
- no-totp-secret
- invalid-totp
- mfa-type-not-found
- totp-already-active
- invalid-state
- oauth-token-echange-failed
- oauth-profile-fetch-failed
- oauth-provider-error
- invalid-otp
- cannot-send-sms
required:
- status
- message
- error

View File

@@ -0,0 +1,6 @@
package: api
generate:
gin-server: true
embedded-spec: true
strict-server: true
output: server.gen.go

View File

@@ -0,0 +1,351 @@
// Package api provides primitives to interact with the openapi HTTP API.
//
// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version 2.5.0 DO NOT EDIT.
package api
import (
"bytes"
"compress/gzip"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/url"
"path"
"strings"
"github.com/getkin/kin-openapi/openapi3"
"github.com/gin-gonic/gin"
strictgin "github.com/oapi-codegen/runtime/strictmiddleware/gin"
)
// ServerInterface represents all server handlers.
type ServerInterface interface {
// Sign in with email and password
// (POST /signin/email-password)
SignInEmailPassword(c *gin.Context)
// Change user email
// (POST /user/email/change)
ChangeUserEmail(c *gin.Context)
}
// ServerInterfaceWrapper converts contexts to parameters.
type ServerInterfaceWrapper struct {
Handler ServerInterface
HandlerMiddlewares []MiddlewareFunc
ErrorHandler func(*gin.Context, error, int)
}
type MiddlewareFunc func(c *gin.Context)
// SignInEmailPassword operation middleware
func (siw *ServerInterfaceWrapper) SignInEmailPassword(c *gin.Context) {
for _, middleware := range siw.HandlerMiddlewares {
middleware(c)
if c.IsAborted() {
return
}
}
siw.Handler.SignInEmailPassword(c)
}
// ChangeUserEmail operation middleware
func (siw *ServerInterfaceWrapper) ChangeUserEmail(c *gin.Context) {
c.Set(BearerAuthElevatedScopes, []string{})
for _, middleware := range siw.HandlerMiddlewares {
middleware(c)
if c.IsAborted() {
return
}
}
siw.Handler.ChangeUserEmail(c)
}
// GinServerOptions provides options for the Gin server.
type GinServerOptions struct {
BaseURL string
Middlewares []MiddlewareFunc
ErrorHandler func(*gin.Context, error, int)
}
// RegisterHandlers creates http.Handler with routing matching OpenAPI spec.
func RegisterHandlers(router gin.IRouter, si ServerInterface) {
RegisterHandlersWithOptions(router, si, GinServerOptions{})
}
// RegisterHandlersWithOptions creates http.Handler with additional options
func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options GinServerOptions) {
errorHandler := options.ErrorHandler
if errorHandler == nil {
errorHandler = func(c *gin.Context, err error, statusCode int) {
c.JSON(statusCode, gin.H{"msg": err.Error()})
}
}
wrapper := ServerInterfaceWrapper{
Handler: si,
HandlerMiddlewares: options.Middlewares,
ErrorHandler: errorHandler,
}
router.POST(options.BaseURL+"/signin/email-password", wrapper.SignInEmailPassword)
router.POST(options.BaseURL+"/user/email/change", wrapper.ChangeUserEmail)
}
type SignInEmailPasswordRequestObject struct {
Body *SignInEmailPasswordJSONRequestBody
}
type SignInEmailPasswordResponseObject interface {
VisitSignInEmailPasswordResponse(w http.ResponseWriter) error
}
type SignInEmailPassword200JSONResponse SignInEmailPasswordResponse
func (response SignInEmailPassword200JSONResponse) VisitSignInEmailPasswordResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
return json.NewEncoder(w).Encode(response)
}
type SignInEmailPassworddefaultJSONResponse struct {
Body ErrorResponse
StatusCode int
}
func (response SignInEmailPassworddefaultJSONResponse) VisitSignInEmailPasswordResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(response.StatusCode)
return json.NewEncoder(w).Encode(response.Body)
}
type ChangeUserEmailRequestObject struct {
Body *ChangeUserEmailJSONRequestBody
}
type ChangeUserEmailResponseObject interface {
VisitChangeUserEmailResponse(w http.ResponseWriter) error
}
type ChangeUserEmail200JSONResponse OKResponse
func (response ChangeUserEmail200JSONResponse) VisitChangeUserEmailResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
return json.NewEncoder(w).Encode(response)
}
type ChangeUserEmaildefaultJSONResponse struct {
Body ErrorResponse
StatusCode int
}
func (response ChangeUserEmaildefaultJSONResponse) VisitChangeUserEmailResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(response.StatusCode)
return json.NewEncoder(w).Encode(response.Body)
}
// StrictServerInterface represents all server handlers.
type StrictServerInterface interface {
// Sign in with email and password
// (POST /signin/email-password)
SignInEmailPassword(ctx context.Context, request SignInEmailPasswordRequestObject) (SignInEmailPasswordResponseObject, error)
// Change user email
// (POST /user/email/change)
ChangeUserEmail(ctx context.Context, request ChangeUserEmailRequestObject) (ChangeUserEmailResponseObject, error)
}
type StrictHandlerFunc = strictgin.StrictGinHandlerFunc
type StrictMiddlewareFunc = strictgin.StrictGinMiddlewareFunc
func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface {
return &strictHandler{ssi: ssi, middlewares: middlewares}
}
type strictHandler struct {
ssi StrictServerInterface
middlewares []StrictMiddlewareFunc
}
// SignInEmailPassword operation middleware
func (sh *strictHandler) SignInEmailPassword(ctx *gin.Context) {
var request SignInEmailPasswordRequestObject
var body SignInEmailPasswordJSONRequestBody
if err := ctx.ShouldBindJSON(&body); err != nil {
ctx.Status(http.StatusBadRequest)
ctx.Error(err)
return
}
request.Body = &body
handler := func(ctx *gin.Context, request interface{}) (interface{}, error) {
return sh.ssi.SignInEmailPassword(ctx, request.(SignInEmailPasswordRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "SignInEmailPassword")
}
response, err := handler(ctx, request)
if err != nil {
ctx.Error(err)
ctx.Status(http.StatusInternalServerError)
} else if validResponse, ok := response.(SignInEmailPasswordResponseObject); ok {
if err := validResponse.VisitSignInEmailPasswordResponse(ctx.Writer); err != nil {
ctx.Error(err)
}
} else if response != nil {
ctx.Error(fmt.Errorf("unexpected response type: %T", response))
}
}
// ChangeUserEmail operation middleware
func (sh *strictHandler) ChangeUserEmail(ctx *gin.Context) {
var request ChangeUserEmailRequestObject
var body ChangeUserEmailJSONRequestBody
if err := ctx.ShouldBindJSON(&body); err != nil {
ctx.Status(http.StatusBadRequest)
ctx.Error(err)
return
}
request.Body = &body
handler := func(ctx *gin.Context, request interface{}) (interface{}, error) {
return sh.ssi.ChangeUserEmail(ctx, request.(ChangeUserEmailRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "ChangeUserEmail")
}
response, err := handler(ctx, request)
if err != nil {
ctx.Error(err)
ctx.Status(http.StatusInternalServerError)
} else if validResponse, ok := response.(ChangeUserEmailResponseObject); ok {
if err := validResponse.VisitChangeUserEmailResponse(ctx.Writer); err != nil {
ctx.Error(err)
}
} else if response != nil {
ctx.Error(fmt.Errorf("unexpected response type: %T", response))
}
}
// Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{
"H4sIAAAAAAAC/9RYUXPbuBH+KxhcO30RJMVW0lhP9WV8rXLXS8Z22s7YfoCApYiYBHhY0IrO1X/vLEBK",
"pEQ1vs5dp32yTBCL3f2+b3fBZ65cWTkLNiCfP3NUOZQy/rzy3vlrwMpZBHogtTbBOCuLj95V4IMB5PNM",
"FggjrgGVNxWt8zm/CdJq6bX5GTQDMsR8a2nEq872Zx6X6UffRDyeKaeBGQ02mGxj7IqFHBhWoExmFJNV",
"VRglaUc6hY842Lrk8zuuIZN1EYR3BYiyxiCWIIwVsijcGnR8jnzEtUG5LEALsLpyxobusxoh2iylKYQs",
"PEi9ISN1jKP/+Am8yQxoPuKZ80ujNVghrbOb0tV0krEBvJWFQPBP4EXrsbFPsjBaJHOVRFw7rzsLHn6q",
"AcmxwilZgLAutHFQOpsdIjgnMHc+dB8aK3KzrISWQS5l9NuDNh5UuHUHlmKu+o/QrGxdiTYjfMRr20ba",
"pof+pG29aJPzlQy9UDIPmIvgHsF2ngejHqGX+jKTIrhQ8RG3Lv4SCMpD11qzHl/dVMn1zNWW3Iw7Wmyk",
"CuYJOjsxyED/O1mHxhsBKpd2BSKTJkWaFivvMlOAyCCofGDxyegBMJNnSlryCcFqgSXyhxEnR/mcY/DG",
"rvh2xEtAlCs4VsBf6lJakXkDVhebRkbt2yMOX2RZFWRrkc5kkUAsc76MOT86iYKuceCg29uPLC02p5Ds",
"ukfMptOdPaLxCjzfbolJP9XGgybBNdb3AY0aae+DdsvPoAK58uH7F1eWVtAfvh9M3w0gxjB+UYH6hOAZ",
"IUiVpakgmCwx5WyQxsZqQ8RAJq1mRHJmbMouGTmsY1IpQLyNxD5K8fu/3yZjBE/vYLtilx8XrNE49oCF",
"zft8+WdlPpj3i08/L179aBa4sNev1bvFm8Vj9Y+/vXt/MR6Ph7DueHP1pTIecDHgVlxK0QdTAnNZLLBp",
"c+OwocwoZ3XPtwtiREO1yIk3M35MEWJIFPyJtMTHlFrNgmPNu0cu9HJyps5fL99k50LNlhdi9hbOxcUf",
"30qhZ3qavdKzMzibxfoXqNryOb+/X95NxYUU2cPz2+39/VLs/p1tT/7u7np1RtuGstyNbqGP41ukzmXA",
"R9wpsF2Q/8uRHUi7S+0T1DpA+ig1Q0Xgxqzswl5R1frYtKvrptX9MjU3u4hDHWkBq5HklcoiabjTWw9m",
"EHrlGD6qEn/A1oDWHrAv0M8ut2MsTcj/ZHOHYWwc78gimR3gzc6RU0d2PN2fdhP81K5iqr6h1n4x++fv",
"qd7KLz+AXYWcz19PR7w0tv33/GvAtg7ujnsxTP/RWNhui2LojzuHxTjkMrBSbpixqqg1MLmrz86zv353",
"yVQuiwLs6niixH1L+J2HjM/5N5P9mDtpZtxJ2zkoKUdBEw4x5HdxKHgpL/uOWFhfDTPrknVa9q9AqQNc",
"dwcf40lzAKjam7C5oUQkT78F6cFf1sSaQ1/T2iFAaxNyRm2tW6nH7FNTy3s6bJsbLVTeBVCB7gXNwI3U",
"wCIo5OcynraPMA+hIkD2Hl4V8CQD6Jd6GqnUZAcZNLtZBb40kQHYuE2sRLBoaFhkBGQ0gHsyNlbYngOs",
"TSYrQWJNJ2CtciaReVBgw4E3Y/ad80xDkKZAhgCMAsT5ZKKdwnEL+aTyTtcq4IS2T1qnRcfpryeNsKaJ",
"hc9tXRQj7iqwsjJ8zs/H0/E09ZI84j+hOd/YycEdZP7MK5dof8DfLrwyDUeRESEH4wdK7phdQ6g9DVN7",
"IUdKHumZmYyFtROZVMEdoWmQgY1XBKLNDiTqvkOViidhAIZvnd5QIDTegU1S3t8fJ58xVYxUHb5aO063",
"rpj2gWlTeYjTgCxwX/96OToIlXc1HXwNUeSpgkbQzqbT3zagpsgPRHR5MDrXsQhkdTFmiyzCucdpxGQH",
"3LUpCrakmkB0AM2MxQBS0+y5o8aYxxPjDf5XC7H/QWMoqOYrAnNK1d6DZuvcFEAli6JrPz/4HdAjjnVZ",
"Sr9puEezclTBwMhBb09IJ0ljk3TZPK2wzkyTXk1jcUd36U5yOJ+M2SVLF/T2w0hcbdOOVJKCi8YsrNtd",
"8RhnM+PLuJSOJNH+u7J5JMDULHe98zcS34nePIDoj7DuZydi4qqmdrcfQtin6x86mmyg+W/Kr3MfHgjj",
"quNWSz/QY3a5RzfkTPZxL4x9ZLlEtgSwp3D/v9FZ02X5/O55cBS4e9g+dOWYqJEa025mkiuk6Sh+tnrY",
"brfbfwUAAP//3ciGL/8UAAA=",
}
// GetSwagger returns the content of the embedded swagger specification file
// or error if failed to decode
func decodeSpec() ([]byte, error) {
zipped, err := base64.StdEncoding.DecodeString(strings.Join(swaggerSpec, ""))
if err != nil {
return nil, fmt.Errorf("error base64 decoding spec: %w", err)
}
zr, err := gzip.NewReader(bytes.NewReader(zipped))
if err != nil {
return nil, fmt.Errorf("error decompressing spec: %w", err)
}
var buf bytes.Buffer
_, err = buf.ReadFrom(zr)
if err != nil {
return nil, fmt.Errorf("error decompressing spec: %w", err)
}
return buf.Bytes(), nil
}
var rawSpec = decodeSpecCached()
// a naive cached of a decoded swagger spec
func decodeSpecCached() func() ([]byte, error) {
data, err := decodeSpec()
return func() ([]byte, error) {
return data, err
}
}
// Constructs a synthetic filesystem for resolving external references when loading openapi specifications.
func PathToRawSpec(pathToFile string) map[string]func() ([]byte, error) {
res := make(map[string]func() ([]byte, error))
if len(pathToFile) > 0 {
res[pathToFile] = rawSpec
}
return res
}
// GetSwagger returns the Swagger specification corresponding to the generated code
// in this file. The external references of Swagger specification are resolved.
// The logic of resolving external references is tightly connected to "import-mapping" feature.
// Externally referenced files must be embedded in the corresponding golang packages.
// Urls can be supported but this task was out of the scope.
func GetSwagger() (swagger *openapi3.T, err error) {
resolvePath := PathToRawSpec("")
loader := openapi3.NewLoader()
loader.IsExternalRefsAllowed = true
loader.ReadFromURIFunc = func(loader *openapi3.Loader, url *url.URL) ([]byte, error) {
pathToFile := url.String()
pathToFile = path.Clean(pathToFile)
getSpec, ok := resolvePath[pathToFile]
if !ok {
err1 := fmt.Errorf("path not found: %s", pathToFile)
return nil, err1
}
return getSpec()
}
var specData []byte
specData, err = rawSpec()
if err != nil {
return
}
swagger, err = loader.LoadFromData(specData)
if err != nil {
return
}
return
}

View File

@@ -0,0 +1,4 @@
package: api
generate:
models: true
output: types.gen.go

View File

@@ -0,0 +1,112 @@
// Package api provides primitives to interact with the openapi HTTP API.
//
// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version 2.5.0 DO NOT EDIT.
package api
import (
openapi_types "github.com/oapi-codegen/runtime/types"
)
const (
BearerAuthElevatedScopes = "BearerAuthElevated.Scopes"
)
// Defines values for ErrorResponseError.
const (
CannotSendSms ErrorResponseError = "cannot-send-sms"
DefaultRoleMustBeInAllowedRoles ErrorResponseError = "default-role-must-be-in-allowed-roles"
DisabledEndpoint ErrorResponseError = "disabled-endpoint"
DisabledMfaTotp ErrorResponseError = "disabled-mfa-totp"
DisabledUser ErrorResponseError = "disabled-user"
EmailAlreadyInUse ErrorResponseError = "email-already-in-use"
EmailAlreadyVerified ErrorResponseError = "email-already-verified"
ForbiddenAnonymous ErrorResponseError = "forbidden-anonymous"
InternalServerError ErrorResponseError = "internal-server-error"
InvalidEmailPassword ErrorResponseError = "invalid-email-password"
InvalidOtp ErrorResponseError = "invalid-otp"
InvalidPat ErrorResponseError = "invalid-pat"
InvalidRefreshToken ErrorResponseError = "invalid-refresh-token"
InvalidRequest ErrorResponseError = "invalid-request"
InvalidState ErrorResponseError = "invalid-state"
InvalidTicket ErrorResponseError = "invalid-ticket"
InvalidTotp ErrorResponseError = "invalid-totp"
LocaleNotAllowed ErrorResponseError = "locale-not-allowed"
MfaTypeNotFound ErrorResponseError = "mfa-type-not-found"
NoTotpSecret ErrorResponseError = "no-totp-secret"
OauthProfileFetchFailed ErrorResponseError = "oauth-profile-fetch-failed"
OauthProviderError ErrorResponseError = "oauth-provider-error"
OauthTokenEchangeFailed ErrorResponseError = "oauth-token-echange-failed"
PasswordInHibpDatabase ErrorResponseError = "password-in-hibp-database"
PasswordTooShort ErrorResponseError = "password-too-short"
RedirectToNotAllowed ErrorResponseError = "redirectTo-not-allowed"
RoleNotAllowed ErrorResponseError = "role-not-allowed"
SignupDisabled ErrorResponseError = "signup-disabled"
TotpAlreadyActive ErrorResponseError = "totp-already-active"
UnverifiedUser ErrorResponseError = "unverified-user"
UserNotAnonymous ErrorResponseError = "user-not-anonymous"
)
// Defines values for OKResponse.
const (
OK OKResponse = "OK"
)
// ErrorResponse Standardized error response
type ErrorResponse struct {
// Error Error code identifying the specific application error
Error ErrorResponseError `json:"error"`
// Message Human-friendly error message
Message string `json:"message"`
// Status HTTP status error code
Status int `json:"status"`
}
// ErrorResponseError Error code identifying the specific application error
type ErrorResponseError string
// OKResponse defines model for OKResponse.
type OKResponse string
// Session User authentication session containing tokens and user information
type Session struct {
// AccessToken JWT token for authenticating API requests
AccessToken string `json:"accessToken"`
// AccessTokenExpiresIn Expiration time of the access token in seconds
AccessTokenExpiresIn int64 `json:"accessTokenExpiresIn"`
// RefreshToken Token used to refresh the access token
RefreshToken string `json:"refreshToken"`
// RefreshTokenId Identifier for the refresh token
RefreshTokenId string `json:"refreshTokenId"`
}
// SignInEmailPasswordRequest Request to authenticate using email and password
type SignInEmailPasswordRequest struct {
// Email User's email address
Email openapi_types.Email `json:"email"`
// Password User's password
Password string `json:"password"`
}
// SignInEmailPasswordResponse Response for email-password authentication that may include a session or MFA challenge
type SignInEmailPasswordResponse struct {
// Session User authentication session containing tokens and user information
Session *Session `json:"session,omitempty"`
}
// UserEmailChangeRequest defines model for UserEmailChangeRequest.
type UserEmailChangeRequest struct {
// NewEmail A valid email
NewEmail openapi_types.Email `json:"newEmail"`
}
// SignInEmailPasswordJSONRequestBody defines body for SignInEmailPassword for application/json ContentType.
type SignInEmailPasswordJSONRequestBody = SignInEmailPasswordRequest
// ChangeUserEmailJSONRequestBody defines body for ChangeUserEmail for application/json ContentType.
type ChangeUserEmailJSONRequestBody = UserEmailChangeRequest

View File

@@ -0,0 +1,49 @@
package controller
import (
"context"
"errors"
"net/http"
"github.com/nhost/nhost/internal/lib/oapi/example/api"
)
type Controller struct{}
func NewController() *Controller {
return &Controller{}
}
func (c *Controller) SignInEmailPassword( //nolint:ireturn
_ context.Context, req api.SignInEmailPasswordRequestObject,
) (api.SignInEmailPasswordResponseObject, error) {
switch req.Body.Email {
case "bad@email.com":
return api.SignInEmailPassworddefaultJSONResponse{
Body: api.ErrorResponse{
Error: api.DisabledUser,
Message: "The user account is disabled.",
Status: http.StatusConflict,
},
StatusCode: http.StatusConflict,
}, nil
case "crash@email.com":
return nil, errors.New("simulated server crash") //nolint:err113
}
return api.SignInEmailPassword200JSONResponse{
Session: &api.Session{
AccessToken: "access_token_example",
AccessTokenExpiresIn: 900, //nolint:mnd
RefreshToken: "refresh_token_example",
RefreshTokenId: "refresh_token_id_example",
},
}, nil
}
func (c *Controller) ChangeUserEmail( //nolint:ireturn
_ context.Context,
_ api.ChangeUserEmailRequestObject,
) (api.ChangeUserEmailResponseObject, error) {
return api.ChangeUserEmail200JSONResponse(api.OK), nil
}

View File

@@ -0,0 +1,109 @@
package main
import (
"context"
"fmt"
"log/slog"
"net/http"
"os"
"time"
"github.com/getkin/kin-openapi/openapi3filter"
"github.com/gin-gonic/gin"
"github.com/lmittmann/tint"
"github.com/nhost/nhost/internal/lib/oapi"
"github.com/nhost/nhost/internal/lib/oapi/example/api"
"github.com/nhost/nhost/internal/lib/oapi/example/controller"
"github.com/nhost/nhost/internal/lib/oapi/middleware"
)
const apiPrefix = "/"
func getLogger() *slog.Logger {
handler := tint.NewHandler(os.Stdout, &tint.Options{
AddSource: true,
Level: slog.LevelDebug,
TimeFormat: time.StampMilli,
NoColor: false,
ReplaceAttr: nil,
})
return slog.New(handler)
}
func authFn(
ctx context.Context,
input *openapi3filter.AuthenticationInput,
) error {
_, ok := ctx.Value(oapi.GinContextKey).(*gin.Context)
if !ok {
return &oapi.AuthenticatorError{
Scheme: input.SecuritySchemeName,
Code: "unauthorized",
Message: "unable to get context",
}
}
return &oapi.AuthenticatorError{
Scheme: input.SecuritySchemeName,
Code: "unauthorized",
Message: "your access token is invalid",
}
}
func setupRouter(logger *slog.Logger) (*gin.Engine, error) {
ctrl := controller.NewController()
handler := api.NewStrictHandler(ctrl, []api.StrictMiddlewareFunc{})
router, mw, err := oapi.NewRouter(
api.OpenAPISchema,
apiPrefix,
authFn,
middleware.CORSOptions{ //nolint:exhaustruct
AllowedOrigins: []string{"*"},
},
logger,
)
if err != nil {
return nil, fmt.Errorf("failed to create oapi router: %w", err)
}
api.RegisterHandlersWithOptions(
router,
handler,
api.GinServerOptions{
BaseURL: apiPrefix,
Middlewares: []api.MiddlewareFunc{mw},
ErrorHandler: nil,
},
)
return router, nil
}
func run(ctx context.Context) error {
logger := getLogger()
router, err := setupRouter(logger) //nolint:contextcheck
if err != nil {
return err
}
server := &http.Server{ //nolint:exhaustruct
Addr: ":8080",
Handler: router,
ReadHeaderTimeout: 5 * time.Second, //nolint:mnd
}
if err := server.ListenAndServe(); err != nil {
logger.ErrorContext(ctx, "server failed", slog.String("error", err.Error()))
}
return nil
}
func main() {
if err := run(context.Background()); err != nil {
panic(err)
}
}

View File

@@ -0,0 +1,177 @@
package main
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/google/go-cmp/cmp"
)
func makeRequest(
router *gin.Engine,
method, path string,
headers map[string]string,
body io.Reader,
) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, body)
for key, value := range headers {
req.Header.Set(key, value)
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
func TestRequests(t *testing.T) {
t.Parallel()
logger := getLogger()
router, err := setupRouter(logger)
if err != nil {
t.Fatalf("Failed to set up router: %v", err)
}
cases := []struct {
name string
method string
path string
headers map[string]string
body io.Reader
expectedStatus int
expectedResponse string
}{
{
name: "success",
method: http.MethodPost,
path: "/signin/email-password",
headers: map[string]string{
"Content-Type": "application/json",
},
body: strings.NewReader(`{"email": "asd@asd.com", "password": "p4ssw0rd"}`),
expectedStatus: http.StatusOK,
expectedResponse: "{\"session\":{\"accessToken\":\"access_token_example\",\"accessTokenExpiresIn\":900,\"refreshToken\":\"refresh_token_example\",\"refreshTokenId\":\"refresh_token_id_example\"}}\n", //nolint:lll
},
{
name: "expected error",
method: http.MethodPost,
path: "/signin/email-password",
headers: map[string]string{
"Content-Type": "application/json",
},
body: strings.NewReader(
`{"email": "bad@email.com", "password": "p4ssw0rd"}`,
),
expectedStatus: http.StatusConflict,
expectedResponse: "{\"error\":\"disabled-user\",\"message\":\"The user account is disabled.\",\"status\":409}\n", //nolint:lll
},
{
name: "unexpected error",
method: http.MethodPost,
path: "/signin/email-password",
headers: map[string]string{
"Content-Type": "application/json",
},
body: strings.NewReader(
`{"email": "crash@email.com", "password": "p4ssw0rd"}`,
),
expectedStatus: http.StatusInternalServerError,
expectedResponse: `{"errors":"internal-server-error","message":"simulated server crash"}`,
},
{
name: "missing body",
method: http.MethodPost,
path: "/signin/email-password",
headers: map[string]string{
"Content-Type": "application/json",
},
body: nil,
expectedStatus: http.StatusBadRequest,
expectedResponse: `{"error":"request-validation-error","reason":"value is required but missing"}`,
},
{
name: "wrong param",
method: http.MethodPost,
path: "/signin/email-password",
headers: map[string]string{
"Content-Type": "application/json",
},
body: strings.NewReader(
`{"wrong":"asd", "email": "asd@asd.com", "password": "p4ssw0rd"}`,
),
expectedStatus: http.StatusBadRequest,
expectedResponse: `{"error":"schema-validation-error","reason":"property \"wrong\" is unsupported"}`,
},
{
name: "missing param",
method: http.MethodPost,
path: "/signin/email-password",
headers: map[string]string{
"Content-Type": "application/json",
},
body: strings.NewReader(`{"email": "asd@asd.com"}`),
expectedStatus: http.StatusBadRequest,
expectedResponse: `{"error":"schema-validation-error","reason":"property \"password\" is missing"}`,
},
{
name: "invalid param",
method: http.MethodPost,
path: "/signin/email-password",
headers: map[string]string{
"Content-Type": "application/json",
},
body: strings.NewReader(`{"email": "asdasd.com", "password": "p4ssw0rd"}`),
expectedStatus: http.StatusBadRequest,
expectedResponse: `{"errors":"bad-request","message":"email: failed to pass regex validation"}`,
},
{
name: "needs security",
method: http.MethodPost,
path: "/user/email/change",
headers: map[string]string{
"Content-Type": "application/json",
},
body: strings.NewReader(`{"newEmail": "new@asd.com"`),
expectedStatus: http.StatusUnauthorized,
expectedResponse: `{"error":"unauthorized","reason":"your access token is invalid","securityScheme":"BearerAuthElevated"}`, //nolint:lll
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
w := makeRequest(router, tc.method, tc.path, tc.headers, tc.body)
resp := w.Result()
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("Failed to read response body: %v", err)
}
if resp.StatusCode != tc.expectedStatus {
t.Errorf("Expected status %d, got %d", tc.expectedStatus, resp.StatusCode)
}
if diff := cmp.Diff(string(body), tc.expectedResponse); diff != "" {
t.Errorf("Response body mismatch (-want +got):\n%s", diff)
}
})
}
}

View File

@@ -0,0 +1,140 @@
package middleware
import (
"net/http"
"slices"
"strings"
"github.com/gin-gonic/gin"
)
// CORSOptions configures the CORS middleware behavior.
//
// The middleware supports three strategies for handling Access-Control-Allow-Headers:
// - nil (default): Reflects the Access-Control-Request-Headers from the client
// - empty slice: Denies all headers (no Access-Control-Allow-Headers header is set)
// - non-empty slice: Uses the specified headers
type CORSOptions struct {
// AllowedOrigins is a list of origins permitted to make cross-origin requests.
// Use "*" or nil slice to allow all origins.
AllowedOrigins []string
// AllowedMethods is a list of HTTP methods the client is permitted to use.
// Common values: GET, POST, PUT, DELETE, PATCH, OPTIONS.
AllowedMethods []string
// AllowedHeaders controls which headers clients can use in requests.
// - nil: reflects client's Access-Control-Request-Headers (permissive)
// - empty slice: denies all headers
// - non-empty: allows only specified headers
AllowedHeaders []string
// ExposedHeaders lists headers that browsers are allowed to access.
// By default, browsers only expose simple response headers.
ExposedHeaders []string
// AllowCredentials indicates whether the request can include credentials
// (cookies, authorization headers, or TLS client certificates).
AllowCredentials bool
// MaxAge indicates how long (in seconds) the results of a preflight request
// can be cached. Empty string means no caching directive is sent.
MaxAge string
}
// CORS returns a Gin middleware handler that implements Cross-Origin Resource Sharing (CORS).
//
// The middleware handles both preflight (OPTIONS) requests and actual requests, setting
// appropriate CORS headers based on the provided configuration. It automatically adds
// the "Vary: Origin, Access-Control-Request-Method" header for proper cache behavior.
//
// For preflight requests (OPTIONS), the middleware responds with 204 No Content and
// prevents further request processing. For actual requests, it sets CORS headers and
// continues the middleware chain.
//
// Example usage:
//
// router.Use(middleware.CORS(middleware.CORSOptions{
// AllowedOrigins: []string{"https://example.com", "https://app.example.com"},
// AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
// AllowedHeaders: nil, // reflects client headers
// AllowCredentials: true,
// MaxAge: "3600",
// }))
func CORS(opts CORSOptions) gin.HandlerFunc { //nolint:cyclop,funlen
allowedMethods := strings.Join(opts.AllowedMethods, ", ")
exposedHeaders := strings.Join(opts.ExposedHeaders, ", ")
allowCredentials := "false"
if opts.AllowCredentials {
allowCredentials = "true"
}
var (
headerStrategy string // "reflect", "specific", or "deny"
allowedHeaders string
)
switch {
case opts.AllowedHeaders == nil:
headerStrategy = "reflect"
case len(opts.AllowedHeaders) == 0:
headerStrategy = "deny"
default:
headerStrategy = "specific"
allowedHeaders = strings.Join(opts.AllowedHeaders, ", ")
}
f := func(c *gin.Context, origin string) {
if opts.AllowedOrigins != nil &&
!slices.Contains(opts.AllowedOrigins, origin) &&
!slices.Contains(opts.AllowedOrigins, "*") {
return
}
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Access-Control-Allow-Methods", allowedMethods)
// Handle allowed headers based on strategy
switch headerStrategy {
case "specific":
c.Header("Access-Control-Allow-Headers", allowedHeaders)
case "reflect":
headers := c.Request.Header.Get("Access-Control-Request-Headers")
if headers != "" {
c.Header("Access-Control-Allow-Headers", headers)
}
case "deny":
// Don't set the header at all
}
if exposedHeaders != "" {
c.Header("Access-Control-Expose-Headers", exposedHeaders)
}
c.Header("Access-Control-Allow-Credentials", allowCredentials)
if opts.MaxAge != "" {
c.Header("Access-Control-Max-Age", opts.MaxAge)
}
c.Writer.Header().Add("Vary", "Origin, Access-Control-Request-Method")
}
return func(c *gin.Context) {
origin := c.Request.Header.Get("Origin")
if c.Request.Method == http.MethodOptions {
f(c, origin)
c.Header("Content-Length", "0")
c.AbortWithStatus(http.StatusNoContent)
return
}
if origin != "" {
f(c, origin)
}
c.Next()
}
}

View File

@@ -0,0 +1,323 @@
package middleware_test
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/google/go-cmp/cmp"
"github.com/nhost/nhost/internal/lib/oapi/middleware"
)
func TestCORS(t *testing.T) { //nolint:maintidx
t.Parallel()
gin.SetMode(gin.TestMode)
cases := []struct {
name string
opts middleware.CORSOptions
requestMethod string
requestOrigin string
requestHeaders map[string]string
wantStatus int
wantHeaders http.Header
expectNext bool
}{
{
name: "OPTIONS request with allowed origin",
opts: middleware.CORSOptions{ //nolint:exhaustruct
AllowedOrigins: []string{"https://example.com"},
AllowedMethods: []string{"GET", "POST"},
AllowedHeaders: []string{"Content-Type", "Authorization"},
},
requestMethod: "OPTIONS",
requestHeaders: map[string]string{},
requestOrigin: "https://example.com",
wantStatus: http.StatusNoContent,
wantHeaders: http.Header{
"Access-Control-Allow-Origin": []string{"https://example.com"},
"Access-Control-Allow-Methods": []string{"GET, POST"},
"Access-Control-Allow-Headers": []string{"Content-Type, Authorization"},
"Access-Control-Allow-Credentials": []string{"false"},
"Vary": []string{
"Origin, Access-Control-Request-Method",
},
"Content-Length": []string{"0"},
},
expectNext: false,
},
{
name: "OPTIONS request with wildcard origin",
opts: middleware.CORSOptions{ //nolint:exhaustruct
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
},
requestMethod: "OPTIONS",
requestHeaders: map[string]string{},
requestOrigin: "https://any-origin.com",
wantStatus: http.StatusNoContent,
wantHeaders: http.Header{
"Access-Control-Allow-Origin": []string{"https://any-origin.com"},
"Access-Control-Allow-Methods": []string{"GET, POST, PUT, DELETE"},
},
expectNext: false,
},
{
name: "OPTIONS request with disallowed origin",
opts: middleware.CORSOptions{ //nolint:exhaustruct
AllowedOrigins: []string{"https://example.com"},
AllowedMethods: []string{"GET", "POST"},
},
requestMethod: "OPTIONS",
requestHeaders: map[string]string{},
requestOrigin: "https://malicious.com",
wantStatus: http.StatusNoContent,
wantHeaders: http.Header{},
expectNext: false,
},
{
name: "OPTIONS request with reflected headers (nil)",
opts: middleware.CORSOptions{ //nolint:exhaustruct
AllowedOrigins: []string{"https://example.com"},
AllowedMethods: []string{"POST"},
AllowedHeaders: nil,
},
requestMethod: "OPTIONS",
requestOrigin: "https://example.com",
requestHeaders: map[string]string{
"Access-Control-Request-Headers": "X-Custom-Header, X-Another-Header",
},
wantStatus: http.StatusNoContent,
wantHeaders: http.Header{
"Access-Control-Allow-Headers": []string{"X-Custom-Header, X-Another-Header"},
},
expectNext: false,
},
{
name: "OPTIONS request with denied headers (empty slice)",
opts: middleware.CORSOptions{ //nolint:exhaustruct
AllowedOrigins: []string{"https://example.com"},
AllowedMethods: []string{"POST"},
AllowedHeaders: []string{},
},
requestMethod: "OPTIONS",
requestOrigin: "https://example.com",
requestHeaders: map[string]string{
"Access-Control-Request-Headers": "X-Custom-Header, X-Another-Header",
},
wantStatus: http.StatusNoContent,
wantHeaders: http.Header{},
expectNext: false,
},
{
name: "OPTIONS request with nil headers and no request headers",
opts: middleware.CORSOptions{ //nolint:exhaustruct
AllowedOrigins: []string{"https://example.com"},
AllowedMethods: []string{"GET"},
AllowedHeaders: nil,
},
requestMethod: "OPTIONS",
requestOrigin: "https://example.com",
requestHeaders: map[string]string{},
wantStatus: http.StatusNoContent,
wantHeaders: http.Header{},
expectNext: false,
},
{
name: "OPTIONS request with credentials enabled",
opts: middleware.CORSOptions{ //nolint:exhaustruct
AllowedOrigins: []string{"https://example.com"},
AllowedMethods: []string{"GET"},
AllowCredentials: true,
},
requestMethod: "OPTIONS",
requestOrigin: "https://example.com",
requestHeaders: map[string]string{},
wantStatus: http.StatusNoContent,
wantHeaders: http.Header{
"Access-Control-Allow-Credentials": []string{"true"},
},
expectNext: false,
},
{
name: "OPTIONS request with MaxAge",
opts: middleware.CORSOptions{ //nolint:exhaustruct
AllowedOrigins: []string{"https://example.com"},
AllowedMethods: []string{"GET"},
MaxAge: "3600",
},
requestMethod: "OPTIONS",
requestOrigin: "https://example.com",
requestHeaders: map[string]string{},
wantStatus: http.StatusNoContent,
wantHeaders: http.Header{
"Access-Control-Max-Age": []string{"3600"},
},
expectNext: false,
},
{
name: "OPTIONS request with exposed headers",
opts: middleware.CORSOptions{ //nolint:exhaustruct
AllowedOrigins: []string{"https://example.com"},
AllowedMethods: []string{"GET"},
ExposedHeaders: []string{"X-Custom-Response", "X-Total-Count"},
},
requestMethod: "OPTIONS",
requestOrigin: "https://example.com",
requestHeaders: map[string]string{},
wantStatus: http.StatusNoContent,
wantHeaders: http.Header{
"Access-Control-Expose-Headers": []string{"X-Custom-Response, X-Total-Count"},
},
expectNext: false,
},
{
name: "GET request with allowed origin",
opts: middleware.CORSOptions{ //nolint:exhaustruct
AllowedOrigins: []string{"https://example.com"},
AllowedMethods: []string{"GET", "POST"},
AllowedHeaders: []string{"Content-Type"},
},
requestMethod: "GET",
requestOrigin: "https://example.com",
requestHeaders: map[string]string{},
wantStatus: http.StatusOK,
wantHeaders: http.Header{
"Access-Control-Allow-Origin": []string{"https://example.com"},
"Access-Control-Allow-Methods": []string{"GET, POST"},
"Access-Control-Allow-Headers": []string{"Content-Type"},
},
expectNext: true,
},
{
name: "POST request with disallowed origin",
opts: middleware.CORSOptions{ //nolint:exhaustruct
AllowedOrigins: []string{"https://example.com"},
AllowedMethods: []string{"GET", "POST"},
},
requestMethod: "POST",
requestOrigin: "https://malicious.com",
requestHeaders: map[string]string{},
wantStatus: http.StatusOK,
wantHeaders: http.Header{},
expectNext: true,
},
{
name: "GET request without origin header",
opts: middleware.CORSOptions{ //nolint:exhaustruct
AllowedOrigins: []string{"https://example.com"},
AllowedMethods: []string{"GET"},
},
requestMethod: "GET",
requestOrigin: "",
requestHeaders: map[string]string{},
wantStatus: http.StatusOK,
wantHeaders: http.Header{},
expectNext: true,
},
{
name: "GET request with empty allowed origins (denies all)",
opts: middleware.CORSOptions{ //nolint:exhaustruct
AllowedOrigins: []string{},
AllowedMethods: []string{"GET"},
},
requestMethod: "GET",
requestOrigin: "https://any-origin.com",
requestHeaders: map[string]string{},
wantStatus: http.StatusOK,
wantHeaders: http.Header{},
expectNext: true,
},
{
name: "GET request with nil allowed origins (allows all)",
opts: middleware.CORSOptions{ //nolint:exhaustruct
AllowedOrigins: nil,
AllowedMethods: []string{"GET"},
},
requestMethod: "GET",
requestOrigin: "https://any-origin.com",
requestHeaders: map[string]string{},
wantStatus: http.StatusOK,
wantHeaders: http.Header{
"Access-Control-Allow-Origin": []string{"https://any-origin.com"},
},
expectNext: true,
},
{
name: "GET request with multiple allowed origins",
opts: middleware.CORSOptions{ //nolint:exhaustruct
AllowedOrigins: []string{
"https://example.com",
"https://another-example.com",
"https://third-example.com",
},
AllowedMethods: []string{"GET"},
},
requestMethod: "GET",
requestHeaders: map[string]string{},
requestOrigin: "https://another-example.com",
wantStatus: http.StatusOK,
wantHeaders: http.Header{
"Access-Control-Allow-Origin": []string{"https://another-example.com"},
},
expectNext: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// Setup router with CORS middleware
router := gin.New()
nextCalled := false
router.Use(middleware.CORS(tc.opts))
router.Any("/test", func(c *gin.Context) {
nextCalled = true
c.Status(http.StatusOK)
})
// Create request
req := httptest.NewRequest(tc.requestMethod, "/test", nil)
if tc.requestOrigin != "" {
req.Header.Set("Origin", tc.requestOrigin)
}
// Add any additional request headers
for key, value := range tc.requestHeaders {
req.Header.Set(key, value)
}
// Record response
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Check status code
if w.Code != tc.wantStatus {
t.Errorf("expected status %d, got %d", tc.wantStatus, w.Code)
}
// Check expected headers using cmp.Diff
// Only compare headers that are expected
gotHeaders := make(http.Header)
for key := range tc.wantHeaders {
if values := w.Header().Values(key); len(values) > 0 {
gotHeaders[key] = values
}
}
if diff := cmp.Diff(tc.wantHeaders, gotHeaders); diff != "" {
t.Errorf("response headers mismatch (-want +got):\n%s", diff)
}
// Check if Next() was called
if nextCalled != tc.expectNext {
t.Errorf("expected Next() called to be %v, got %v", tc.expectNext, nextCalled)
}
})
}
}

View File

@@ -30,6 +30,7 @@ func LoggerFromContext(ctx context.Context) *slog.Logger { //nolint:contextcheck
return logger
}
// Logger is a Gin middleware that logs HTTP requests and responses using slog.
func Logger(logger *slog.Logger) gin.HandlerFunc {
return func(ctx *gin.Context) {
startTime := time.Now()

69
internal/lib/oapi/oapi.go Normal file
View File

@@ -0,0 +1,69 @@
package oapi
import (
"fmt"
"log/slog"
"net/http"
"github.com/getkin/kin-openapi/openapi3"
"github.com/getkin/kin-openapi/openapi3filter"
"github.com/gin-gonic/gin"
"github.com/nhost/nhost/internal/lib/oapi/example/api"
"github.com/nhost/nhost/internal/lib/oapi/middleware"
)
func surfaceErrorsMiddleWare(c *gin.Context) {
// this captures two cases as far as I can see:
// 1. request validation errors where the strict generated code fails
// to bind the request to the struct (i.e. "invalid param" test)
// 2. when a handler returns an error instead of a response
c.Next()
if len(c.Errors) > 0 && !c.IsAborted() {
var errorCode string
switch c.Writer.Status() {
case http.StatusBadRequest:
errorCode = "bad-request"
default:
errorCode = "internal-server-error"
}
c.JSON(
c.Writer.Status(),
gin.H{"errors": errorCode, "message": c.Errors[0].Error()},
)
}
}
// NewRouter creates a Gin router with OpenAPI request validation middleware.
func NewRouter(
schema []byte,
apiPrefix string,
authenticationFunc openapi3filter.AuthenticationFunc,
corsOptions middleware.CORSOptions,
logger *slog.Logger,
) (*gin.Engine, func(c *gin.Context), error) {
router := gin.New()
loader := openapi3.NewLoader()
doc, err := loader.LoadFromData(schema)
if err != nil {
return nil, nil, fmt.Errorf("failed to load OpenAPI schema: %w", err)
}
doc.AddServer(&openapi3.Server{ //nolint:exhaustruct
URL: apiPrefix,
})
router.Use(
gin.Recovery(),
surfaceErrorsMiddleWare,
middleware.Logger(logger),
middleware.CORS(corsOptions),
)
mw := api.MiddlewareFunc(requestValidatorWithOptions(doc, authenticationFunc))
return router, mw, nil
}

View File

@@ -0,0 +1,128 @@
package oapi
import (
"context"
"errors"
"fmt"
"net/http"
"github.com/getkin/kin-openapi/openapi3"
"github.com/getkin/kin-openapi/openapi3filter"
"github.com/getkin/kin-openapi/routers"
"github.com/getkin/kin-openapi/routers/gorillamux"
"github.com/gin-gonic/gin"
)
type ContextKey string
const (
GinContextKey ContextKey = "nhost-oapi/gin-context"
)
func handleError(c *gin.Context, err error) {
var (
errReq *openapi3filter.RequestError
errSchema *openapi3.SchemaError
errAuth *AuthenticatorError
errSec *openapi3filter.SecurityRequirementsError
)
switch {
case errors.As(err, &errSchema):
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
"error": "schema-validation-error",
"reason": errSchema.Reason,
})
case errors.As(err, &errReq):
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
"error": "request-validation-error",
"reason": errReq.Err.Error(),
})
case errors.As(err, &errAuth):
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": errAuth.Code,
"reason": errAuth.Message,
"securityScheme": errAuth.Scheme,
})
case errors.As(err, &errSec):
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "unauthorized",
"reason": errSec.Error(),
})
default:
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
}
}
func requestValidatorWithOptions(
swagger *openapi3.T,
authFn openapi3filter.AuthenticationFunc,
) gin.HandlerFunc {
router, err := gorillamux.NewRouter(swagger)
if err != nil {
panic(err)
}
return func(c *gin.Context) {
if err := validateRequestFromContext(c, router, authFn); err != nil {
handleError(c, err)
}
c.Next()
}
}
func validateRequestFromContext(
c *gin.Context,
router routers.Router,
authFn openapi3filter.AuthenticationFunc,
) error {
route, pathParams, err := router.FindRoute(c.Request)
if err != nil {
var e *routers.RouteError
switch {
case errors.As(err, &e):
return e
default:
return fmt.Errorf("error validating route: %w", err)
}
}
validationInput := &openapi3filter.RequestValidationInput{ //nolint:exhaustruct
Request: c.Request,
PathParams: pathParams,
Route: route,
Options: &openapi3filter.Options{
AuthenticationFunc: authFn,
ExcludeRequestBody: false,
ExcludeRequestQueryParams: false,
ExcludeResponseBody: false,
ExcludeReadOnlyValidations: false,
ExcludeWriteOnlyValidations: false,
IncludeResponseStatus: false,
MultiError: false,
RegexCompiler: nil,
SkipSettingDefaults: false,
},
}
requestContext := context.WithValue(c.Request.Context(), GinContextKey, c)
if err := openapi3filter.ValidateRequest(requestContext, validationInput); err != nil {
return err //nolint:wrapcheck
}
return nil
}
func GetGinContext(c context.Context) *gin.Context {
v := c.Value(GinContextKey)
if v == nil {
return nil
}
ginCtx, ok := v.(*gin.Context)
if !ok {
return nil
}
return ginCtx
}

View File

@@ -56,12 +56,17 @@ in
{ buildInputs ? [ ]
, shellHook ? ""
}: pkgs.mkShell {
inherit shellHook;
buildInputs = with pkgs; [
gnumake
nixpkgs-fmt
] ++ goCheckDeps ++ buildInputs;
shellHook = shellHook + pkgs.lib.optionalString pkgs.stdenv.isDarwin ''
export SDKROOT=${pkgs.apple-sdk_12}
export SDKROOT_FOR_TARGET=${pkgs.apple-sdk_12}
export DEVELOPER_DIR=${pkgs.apple-sdk_12}
export DEVELOPER_DIR_FOR_TARGET=${pkgs.apple-sdk_12}
'';
};
check =
@@ -109,13 +114,13 @@ in
echo " Running golangci-lint"
golangci-lint run \
--timeout 600s \
./${submodule}/...
./...
echo " Running tests"
richgo test \
-tags="${pkgs.lib.strings.concatStringsSep " " tags}" \
-ldflags="${pkgs.lib.strings.concatStringsSep " " ldflags}" \
-v ${goTestFlags} ./${submodule}/...
-v ${goTestFlags} ./...
${extraCheck}

Some files were not shown because too many files have changed in this diff Show More