diff --git a/.changeset/config.json b/.changeset/config.json
index d5dfa3d2..844e69b6 100644
--- a/.changeset/config.json
+++ b/.changeset/config.json
@@ -2,7 +2,7 @@
"$schema": "https://unpkg.com/@changesets/config@3.0.4/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": "./commit.cjs",
- "fixed": [["@openauthjs/openauth"]],
+ "fixed": [["@openauthjs/openauth", "@openauthjs/solid", "@openauthjs/react"]],
"linked": [],
"access": "public",
"baseBranch": "master",
diff --git a/.changeset/lemon-vans-thank.md b/.changeset/lemon-vans-thank.md
new file mode 100644
index 00000000..c68293de
--- /dev/null
+++ b/.changeset/lemon-vans-thank.md
@@ -0,0 +1,7 @@
+---
+"@openauthjs/openauth": patch
+"@openauthjs/solid": patch
+"@openauthjs/react": patch
+---
+
+1.0
diff --git a/.changeset/long-snakes-happen.md b/.changeset/long-snakes-happen.md
new file mode 100644
index 00000000..24caf852
--- /dev/null
+++ b/.changeset/long-snakes-happen.md
@@ -0,0 +1,6 @@
+---
+"@openauthjs/react": patch
+"@openauthjs/solid": patch
+---
+
+provider opt
diff --git a/bun.lockb b/bun.lockb
index 7f4b8658..615d2c86 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/examples/client/cloudflare-api/package.json b/examples/client/cloudflare-api/package.json
index cd283a40..eee1b4ae 100644
--- a/examples/client/cloudflare-api/package.json
+++ b/examples/client/cloudflare-api/package.json
@@ -1,5 +1,5 @@
{
- "name": "cloudflare-api",
+ "name": "@openauthjs/example-cloudflare-api",
"version": "0.0.0",
"private": true
}
diff --git a/examples/client/go/issuer.ts b/examples/client/go/issuer.ts
new file mode 100644
index 00000000..fcaad24f
--- /dev/null
+++ b/examples/client/go/issuer.ts
@@ -0,0 +1,34 @@
+import { handle } from "hono/aws-lambda"
+import { issuer } from "@openauthjs/openauth/issuer"
+import { CodeProvider } from "@openauthjs/openauth/provider/code"
+import { CodeUI } from "@openauthjs/openauth/ui/code"
+import { subjects } from "./subjects"
+
+async function getUser(email: string) {
+ // Get user from database
+ // Return user ID
+ return "123"
+}
+
+const app = issuer({
+ subjects,
+ providers: {
+ code: CodeProvider(
+ CodeUI({
+ async sendCode(claims, code) {
+ console.log("sendCode", claims, code)
+ },
+ }),
+ ),
+ },
+ success: async (ctx, value) => {
+ if (value.provider === "code") {
+ const id = await getUser(value.claims.email)
+ return ctx.subject("user", id, { id })
+ }
+ throw new Error("Invalid provider")
+ },
+})
+
+// @ts-ignore
+export const handler = handle(app)
diff --git a/examples/client/go/package.json b/examples/client/go/package.json
new file mode 100644
index 00000000..29e80402
--- /dev/null
+++ b/examples/client/go/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "@openauthjs/example-client-go",
+ "type": "module",
+ "version": "0.0.0",
+ "dependencies": {
+ "@openauthjs/openauth": "workspace:*",
+ "sst": "3.5.1"
+ }
+}
diff --git a/examples/client/go/src/go.mod b/examples/client/go/src/go.mod
new file mode 100644
index 00000000..5e42761a
--- /dev/null
+++ b/examples/client/go/src/go.mod
@@ -0,0 +1,26 @@
+module example-client-go
+
+go 1.23.5
+
+require (
+ github.com/aws/aws-lambda-go v1.48.0
+ github.com/awslabs/aws-lambda-go-api-proxy v0.16.2
+ github.com/sst/sst/v3 v3.13.10
+ github.com/toolbeam/openauth v0.0.0
+)
+
+require (
+ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
+ github.com/goccy/go-json v0.10.3 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/lestrrat-go/blackmagic v1.0.2 // indirect
+ github.com/lestrrat-go/httpcc v1.0.1 // indirect
+ github.com/lestrrat-go/httprc/v3 v3.0.0-beta1 // indirect
+ github.com/lestrrat-go/jwx/v3 v3.0.0 // indirect
+ github.com/lestrrat-go/option v1.0.1 // indirect
+ github.com/segmentio/asm v1.2.0 // indirect
+ golang.org/x/crypto v0.36.0 // indirect
+ golang.org/x/sys v0.31.0 // indirect
+)
+
+replace github.com/toolbeam/openauth => ../../../../packages/openauth-go
diff --git a/examples/client/go/src/go.sum b/examples/client/go/src/go.sum
new file mode 100644
index 00000000..a2b440f4
--- /dev/null
+++ b/examples/client/go/src/go.sum
@@ -0,0 +1,58 @@
+github.com/aws/aws-lambda-go v1.48.0 h1:1aZUYsrJu0yo5fC4z+Rba1KhNImXcJcvHu763BxoyIo=
+github.com/aws/aws-lambda-go v1.48.0/go.mod h1:dpMpZgvWx5vuQJfBt0zqBha60q7Dd7RfgJv23DymV8A=
+github.com/awslabs/aws-lambda-go-api-proxy v0.16.2 h1:CJyGEyO1CIwOnXTU40urf0mchf6t3voxpvUDikOU9LY=
+github.com/awslabs/aws-lambda-go-api-proxy v0.16.2/go.mod h1:vxxjwBHe/KbgFeNlAP/Tvp4SsVRL3WQamcWRxqVh0z0=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
+github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
+github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
+github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
+github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
+github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
+github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
+github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k=
+github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
+github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
+github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
+github.com/lestrrat-go/httprc/v3 v3.0.0-beta1 h1:pzDjP9dSONCFQC/AE3mWUnHILGiYPiMKzQIS+weKJXA=
+github.com/lestrrat-go/httprc/v3 v3.0.0-beta1/go.mod h1:wdsgouffPvWPEYh8t7PRH/PidR5sfVqt0na4Nhj60Ms=
+github.com/lestrrat-go/jwx/v3 v3.0.0 h1:IRnFNdZx5dJHjTpPVkYqP6TRahJI2Z9v43UwEDJcj6U=
+github.com/lestrrat-go/jwx/v3 v3.0.0/go.mod h1:ak32WoNtHE0aLowVWBcCvXngcAnW4tuC0YhFwOr/kwc=
+github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
+github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
+github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
+github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc=
+github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
+github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
+github.com/onsi/gomega v1.27.7 h1:fVih9JD6ogIiHUN6ePK7HJidyEDpWGVB5mzM7cWNXoU=
+github.com/onsi/gomega v1.27.7/go.mod h1:1p8OOlwo2iUUDsHnOrjE5UKYJ+e3W8eQ3qSlRahPmr4=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
+github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
+github.com/sst/sst/v3 v3.13.10 h1:DohLPpekXerhU8YHM9M8kbx8YB3W4o7tW2qO2b/Ro9g=
+github.com/sst/sst/v3 v3.13.10/go.mod h1:t0+Krbn45bPEvo19BNbP3TACUS6J7TLbM04YvuoX3Gg=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
+golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
+golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
+golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
+golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
+golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
+golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
+gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/examples/client/go/src/main.go b/examples/client/go/src/main.go
new file mode 100644
index 00000000..c6afb7ad
--- /dev/null
+++ b/examples/client/go/src/main.go
@@ -0,0 +1,136 @@
+package main
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+
+ "github.com/aws/aws-lambda-go/lambda"
+ "github.com/awslabs/aws-lambda-go-api-proxy/httpadapter"
+ "github.com/sst/sst/v3/sdk/golang/resource"
+ "github.com/toolbeam/openauth/client"
+ "github.com/toolbeam/openauth/subject"
+)
+
+func getOrigin(u *url.URL) string {
+ return fmt.Sprintf("%s://%s", u.Scheme, u.Host)
+}
+
+type UserSubject struct {
+ Id string `json:"id"`
+}
+
+func main() {
+
+ authUrl, err := resource.Get("Auth", "url")
+ if err != nil {
+ panic(err)
+ }
+ var authUrlString string
+ authUrlString = authUrl.(string)
+ if authUrlString == "" {
+ panic("authUrl is empty")
+ }
+
+ // setup the openauth client
+ authClient, err := client.NewClient(client.ClientInput{
+ ClientID: "lambda-api-go",
+ Issuer: authUrlString,
+ SubjectSchema: subject.SubjectSchemas{
+ "user": func(properties any) (any, error) {
+ user, ok := properties.(map[string]any)
+ if !ok {
+ return nil, errors.New("invalid user type")
+ }
+ if user["id"] == nil {
+ return nil, errors.New("id is required")
+ }
+ // can do other validation here if there are other properties
+ return UserSubject{Id: user["id"].(string)}, nil
+ },
+ },
+ })
+
+ if err != nil {
+ panic(err)
+ }
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ accessCookie, err := r.Cookie("access_token")
+ if err != nil {
+ http.Redirect(w, r, "/authorize", http.StatusSeeOther)
+ return
+ }
+ verified, err := authClient.Verify(accessCookie.Value, &client.VerifyOptions{})
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ if verified.Tokens != nil {
+ setCookies(w, verified.Tokens.Access, verified.Tokens.Refresh)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ // can do check on the type of the subject to get the correct type to cast to
+ if verified.Subject.Type != "user" {
+ http.Error(w, "invalid subject type", http.StatusInternalServerError)
+ return
+ }
+ json.NewEncoder(w).Encode(verified.Subject.Properties.(UserSubject))
+ })
+ mux.HandleFunc("/authorize", func(w http.ResponseWriter, r *http.Request) {
+ origin := getOrigin(r.URL)
+ redirectURI := origin + "/callback"
+ authorize, err := authClient.Authorize(redirectURI, "code", &client.AuthorizeOptions{})
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ http.Redirect(w, r, authorize.URL, http.StatusSeeOther)
+ })
+
+ mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
+ origin := getOrigin(r.URL)
+ if origin == "" {
+ http.Error(w, "Origin header is required", http.StatusBadRequest)
+ return
+ }
+ redirectURI := origin + "/callback"
+ code := r.URL.Query().Get("code")
+ if code == "" {
+ http.Error(w, "Code is required", http.StatusBadRequest)
+ return
+ }
+ exchanged, err := authClient.Exchange(code, redirectURI, &client.ExchangeOptions{})
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ setCookies(w, exchanged.Tokens.Access, exchanged.Tokens.Refresh)
+ http.Redirect(w, r, origin, http.StatusSeeOther)
+ })
+
+ lambda.Start(httpadapter.NewV2(mux).ProxyWithContext)
+}
+
+func setCookies(w http.ResponseWriter, access, refresh string) {
+ http.SetCookie(w, &http.Cookie{
+ Name: "access_token",
+ Value: access,
+ MaxAge: 34560000,
+ SameSite: http.SameSiteStrictMode,
+ Path: "/",
+ HttpOnly: true,
+ })
+ http.SetCookie(w, &http.Cookie{
+ Name: "refresh_token",
+ Value: refresh,
+ MaxAge: 34560000,
+ SameSite: http.SameSiteStrictMode,
+ Path: "/",
+ HttpOnly: true,
+ })
+}
diff --git a/examples/client/go/sst-env.d.ts b/examples/client/go/sst-env.d.ts
new file mode 100644
index 00000000..f3f05451
--- /dev/null
+++ b/examples/client/go/sst-env.d.ts
@@ -0,0 +1,19 @@
+/* This file is auto-generated by SST. Do not edit. */
+/* tslint:disable */
+/* eslint-disable */
+/* deno-fmt-ignore-file */
+import "sst"
+export {}
+declare module "sst" {
+ export interface Resource {
+ "ApiGo": {
+ "name": string
+ "type": "sst.aws.Function"
+ "url": string
+ }
+ "Auth": {
+ "type": "sst.aws.Auth"
+ "url": string
+ }
+ }
+}
diff --git a/examples/client/go/sst.config.ts b/examples/client/go/sst.config.ts
new file mode 100644
index 00000000..ff07d950
--- /dev/null
+++ b/examples/client/go/sst.config.ts
@@ -0,0 +1,29 @@
+///
+
+export default $config({
+ app(input) {
+ return {
+ name: "openauth",
+ removal: input?.stage === "production" ? "retain" : "remove",
+ home: "aws",
+
+ providers: {
+ aws: {
+ region: "us-east-1",
+ },
+ },
+ }
+ },
+ async run() {
+ const auth = new sst.aws.Auth("Auth", {
+ issuer: "./issuer.handler",
+ })
+
+ const apiGo = new sst.aws.Function("ApiGo", {
+ url: true,
+ runtime: "go",
+ handler: "./src",
+ link: [auth],
+ })
+ },
+})
diff --git a/examples/client/go/subjects.ts b/examples/client/go/subjects.ts
new file mode 100644
index 00000000..479fefad
--- /dev/null
+++ b/examples/client/go/subjects.ts
@@ -0,0 +1,8 @@
+import { object, string } from "valibot"
+import { createSubjects } from "@openauthjs/openauth/subject"
+
+export const subjects = createSubjects({
+ user: object({
+ id: string(),
+ }),
+})
diff --git a/examples/client/go/tsconfig.json b/examples/client/go/tsconfig.json
new file mode 100644
index 00000000..0ccdf2e4
--- /dev/null
+++ b/examples/client/go/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "@tsconfig/node22/tsconfig.json",
+ "compilerOptions": {
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "strict": true,
+ "jsx": "react-jsx",
+ "jsxImportSource": "hono/jsx"
+ }
+}
diff --git a/examples/client/sveltekit/package.json b/examples/client/sveltekit/package.json
index 92b35a4a..f7935f6e 100644
--- a/examples/client/sveltekit/package.json
+++ b/examples/client/sveltekit/package.json
@@ -11,7 +11,7 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
- "@openauthjs/openauth": "^0.4.3",
+ "@openauthjs/openauth": "workspace:*",
"@sveltejs/adapter-auto": "^4.0.0",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
diff --git a/examples/issuer/bun/issuer.ts b/examples/issuer/bun/issuer.ts
index 55d4c86d..68b046f4 100644
--- a/examples/issuer/bun/issuer.ts
+++ b/examples/issuer/bun/issuer.ts
@@ -34,7 +34,7 @@ export default issuer({
},
success: async (ctx, value) => {
if (value.provider === "password") {
- return ctx.subject("user", {
+ return ctx.subject("user", "123", {
id: await getUser(value.email),
})
}
diff --git a/examples/quickstart/sst/package.json b/examples/quickstart/sst/package.json
index ff7a4767..be91cc98 100644
--- a/examples/quickstart/sst/package.json
+++ b/examples/quickstart/sst/package.json
@@ -9,7 +9,7 @@
"start": "next start"
},
"dependencies": {
- "@openauthjs/openauth": "^0.3.2",
+ "@openauthjs/openauth": "workspace:*",
"hono": "^4.6.16",
"next": "15.1.4",
"react": "^19.0.0",
diff --git a/examples/quickstart/standalone/package.json b/examples/quickstart/standalone/package.json
index e3c5bc65..f3d2ef0c 100644
--- a/examples/quickstart/standalone/package.json
+++ b/examples/quickstart/standalone/package.json
@@ -10,7 +10,7 @@
"lint": "next lint"
},
"dependencies": {
- "@openauthjs/openauth": "^0.3.2",
+ "@openauthjs/openauth": "workspace:*",
"next": "15.1.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
diff --git a/package.json b/package.json
index 5c5606eb..141f5935 100644
--- a/package.json
+++ b/package.json
@@ -3,12 +3,13 @@
"module": "index.ts",
"type": "module",
"workspaces": [
- "packages/openauth",
+ "packages/*",
"examples/issuer/*",
"examples/client/*"
],
"scripts": {
- "release": "bun run --filter=\"@openauthjs/openauth\" build && changeset publish"
+ "release": "bun run --filter=\"@openauthjs/openauth\" build && bun run --filter=\"@openauthjs/solid\" build && changeset publish",
+ "publish:version": "changeset publish"
},
"devDependencies": {
"@tsconfig/node22": "22.0.0",
diff --git a/packages/openauth-go/client/client.go b/packages/openauth-go/client/client.go
new file mode 100644
index 00000000..8cd1de20
--- /dev/null
+++ b/packages/openauth-go/client/client.go
@@ -0,0 +1,547 @@
+package client
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "os"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/lestrrat-go/jwx/v3/jwk"
+ "github.com/lestrrat-go/jwx/v3/jwt"
+ "github.com/toolbeam/openauth/internal/util"
+ "github.com/toolbeam/openauth/subject"
+)
+
+var (
+ ErrInvalidAuthorizationCode = errors.New("invalid authorization code")
+ ErrInvalidRefreshToken = errors.New("invalid refresh token")
+ ErrInvalidAccessToken = errors.New("invalid access token")
+ ErrInvalidSubject = errors.New("invalid subject")
+ ErrUnknownState = errors.New("The browser was in an unknown state. This could be because certain cookies expired or the browser was switched in the middle of an authentication flow.")
+)
+
+// WellKnown is the well-known information for an OAuth 2.0 authorization server.
+type WellKnown struct {
+ // JWKsUri is the URI to the JWKS endpoint.
+ JWKsUri string `json:"jwks_uri"`
+ // TokenEndpoint is the URI to the token endpoint.
+ TokenEndpoint string `json:"token_endpoint"`
+ // AuthorizationEndpoint is the URI to the authorization endpoint.
+ AuthorizationEndpoint string `json:"authorization_endpoint"`
+}
+
+// Tokens is the tokens returned by the auth server.
+type Tokens struct {
+ // Access is the access token.
+ Access string `json:"access_token"`
+ // Refresh is the refresh token.
+ Refresh string `json:"refresh_token"`
+ // ExpiresIn is the number of seconds until the access token expires.
+ ExpiresIn int `json:"expires_in"`
+}
+
+// Challenge is the challenge that you can use to verify the code.
+type Challenge struct {
+ // State is the state that was sent to the redirect URI.
+ State string
+ // Verifier is the verifier that was sent to the redirect URI.
+ Verifier string
+}
+
+// AuthorizeOptions is the options for the authorize endpoint.
+type AuthorizeOptions struct {
+ // Enable the PKCE flow. This is for SPA apps.
+ PKCE bool
+ // Provider is the provider to use for the OAuth flow.
+ Provider string
+}
+
+// AuthorizeResult is the result of the authorize endpoint.
+type AuthorizeResult struct {
+ // The challenge that you can use to verify the code. This is for the PKCE flow for SPA apps.
+ Challenge Challenge
+ // The URL to redirect the user to. This starts the OAuth flow.
+ URL string
+}
+
+// ExchangeSuccess is the success result of the exchange endpoint.
+type ExchangeSuccess struct {
+ Tokens Tokens
+}
+
+type RefreshOptions struct {
+ // Optionally, pass in the access token.
+ Access string
+}
+
+type RefreshSuccess struct {
+ Tokens *Tokens
+}
+
+type VerifyOptions struct {
+ // Optionally, pass in the refresh token.
+ Refresh string
+ // Optionally, override the internally used HTTP client.
+ HTTPClient *http.Client
+ // @internal
+ issuer string
+ // @internal
+ audience string
+}
+
+type VerifyResult struct {
+ Tokens *Tokens
+ // @internal
+ aud string
+ Subject *Subject
+}
+
+type Subject struct {
+ ID string
+ Type string
+ Properties any
+}
+
+type ExchangeOptions struct {
+ Verifier string
+}
+
+type DecodeSuccess struct {
+ Subject *Subject
+}
+
+type ClientInput struct {
+ // The client ID. This is just a string to identify your app.
+ ClientID string
+ // The URL of your OpenAuth server.
+ Issuer string
+ httpClient *http.Client
+ // The schema of the subject.
+ SubjectSchema subject.SubjectSchemas
+}
+
+type Client struct {
+ clientID string
+ issuer string
+ httpClient *http.Client
+ issuerCache map[string]WellKnown
+ jwksCache map[string]jwk.Set
+ mu sync.RWMutex
+ subjectSchema *subject.SubjectSchemas
+}
+
+func NewClient(input ClientInput) (*Client, error) {
+ httpClient := input.httpClient
+ if httpClient == nil {
+ httpClient = http.DefaultClient
+ }
+ if input.Issuer == "" {
+ input.Issuer = os.Getenv("OPENAUTH_ISSUER")
+ }
+ if input.Issuer == "" {
+ return nil, errors.New("issuer is required")
+ }
+ return &Client{
+ clientID: input.ClientID,
+ issuer: input.Issuer,
+ httpClient: httpClient,
+ issuerCache: map[string]WellKnown{},
+ jwksCache: map[string]jwk.Set{},
+ mu: sync.RWMutex{},
+ subjectSchema: &input.SubjectSchema,
+ }, nil
+}
+
+// getIssuer fetches the well-known configuration from the issuer URL and caches it.
+func (c *Client) getIssuer() (WellKnown, error) {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ if cached, ok := c.issuerCache[c.issuer]; ok {
+ return cached, nil
+ }
+ resp, err := c.httpClient.Get(fmt.Sprintf("%s/.well-known/oauth-authorization-server", c.issuer))
+ if err != nil {
+ return WellKnown{}, fmt.Errorf("failed to fetch well-known config: %w", err)
+ }
+ defer resp.Body.Close()
+ var config WellKnown
+ if err := json.NewDecoder(resp.Body).Decode(&config); err != nil {
+ return WellKnown{}, fmt.Errorf("failed to decode well-known config: %w", err)
+ }
+ c.issuerCache[c.issuer] = config
+ return config, nil
+}
+
+// getJWKS fetches the JWKS from the issuer URL and caches it.
+func (c *Client) getJWKS() (jwk.Set, error) {
+ wellKnown, err := c.getIssuer()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get well-known config: %w", err)
+ }
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if cached, ok := c.jwksCache[wellKnown.JWKsUri]; ok {
+ return cached, nil
+ }
+ set, err := jwk.Fetch(context.Background(), wellKnown.JWKsUri)
+ if err != nil {
+ return nil, fmt.Errorf("failed to fetch JWKS: %w", err)
+ }
+ c.jwksCache[wellKnown.JWKsUri] = set
+ return set, nil
+}
+
+// Start the authorization flow.
+//
+// This takes a redirect URI and the type of flow you want to use. The redirect URI is the
+// location where the user will be redirected after the flow is complete.
+//
+// Supports both the `code` and `token` flows. We recommend using the `code` flow as it's
+// more secure.
+//
+// For SPA apps, we recommend using the PKCE flow.
+//
+// result, err := client.Authorize(redirectURI, "code", &AuthorizeOptions{
+// PKCE: true,
+// })
+//
+// This returns a redirect URL and a challenge that you can use to verify the code.
+func (c *Client) Authorize(redirectURI string, response string, opts *AuthorizeOptions) (*AuthorizeResult, error) {
+ result := &AuthorizeResult{}
+ u, err := url.Parse(c.issuer)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse issuer: %w", err)
+ }
+ u.Path = "/authorize"
+ result.Challenge.State = uuid.New().String()
+ query := url.Values{
+ "client_id": {c.clientID},
+ "redirect_uri": {redirectURI},
+ "response_type": {response},
+ "state": {result.Challenge.State},
+ }
+ if opts != nil && opts.Provider != "" {
+ query.Set("provider", opts.Provider)
+ }
+ if opts != nil && opts.PKCE && response == "code" {
+ verifier, challenge, method, err := util.GeneratePKCE()
+ if err != nil {
+ return nil, fmt.Errorf("failed to generate PKCE: %w", err)
+ }
+ query.Set("code_challenge_method", method)
+ query.Set("code_challenge", challenge)
+ result.Challenge.Verifier = verifier
+ }
+ u.RawQuery = query.Encode()
+ result.URL = u.String()
+ return result, nil
+}
+
+// Exchange the code for access and refresh tokens.
+//
+// You call this after the user has been redirected back to your app after the OAuth flow.
+//
+// For SSR sites, the code is returned in the query parameter.
+//
+// result, err := client.Exchange(code, redirectURI)
+//
+// For SPA sites, the code is returned as a part of the redirect URL hash.
+//
+// result, err := client.Exchange(code, redirectURI, &ExchangeOptions{
+// Verifier: verifier,
+// })
+//
+// This returns the access and refresh tokens. Or if it fails, it returns an error that
+// you can handle depending on the error.
+//
+// if err != nil {
+// if errors.Is(err, ErrInvalidAuthorizationCode) {
+// // handle invalid code error
+// } else {
+// // handle other errors
+// }
+// }
+func (c *Client) Exchange(code string, redirectURI string, opts *ExchangeOptions) (*ExchangeSuccess, error) {
+ endpoint := c.issuer + "/token"
+ data := url.Values{}
+ data.Set("code", code)
+ data.Set("redirect_uri", redirectURI)
+ data.Set("grant_type", "authorization_code")
+ data.Set("client_id", c.clientID)
+ if opts != nil && opts.Verifier != "" {
+ data.Set("code_verifier", opts.Verifier)
+ }
+ req, err := http.NewRequest("POST", endpoint, strings.NewReader(data.Encode()))
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to send request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ fmt.Printf("resp: %+v\n", resp.StatusCode)
+ return nil, ErrInvalidAuthorizationCode
+ }
+
+ var tokens Tokens
+ if err := json.NewDecoder(resp.Body).Decode(&tokens); err != nil {
+ return nil, fmt.Errorf("failed to decode tokens: %w", err)
+ }
+
+ return &ExchangeSuccess{Tokens: tokens}, nil
+}
+
+// Refresh implements the token if they have expires. This is used in an SPA app to maintain the
+// session, without logging the user out.
+//
+// result, err := client.Refresh(refreshToken, &RefreshOptions{})
+//
+// Can optionally take the access token as well. If passed in, this will skip the refresh
+// if the access token is still valid.
+//
+// result, err := client.Refresh(refreshToken, &RefreshOptions{
+// Access: accessToken,
+// })
+//
+// This returns the refreshed tokens only if they've been refreshed.
+//
+// if result.Tokens != nil {
+// // tokens are refreshed
+// }
+//
+// Or if it fails, it returns an error that you can handle depending on the error.
+//
+// if err != nil {
+// if errors.Is(err, ErrInvalidRefreshToken) {
+// // handle invalid refresh token error
+// } else {
+// // handle other errors
+// }
+// }
+func (c *Client) Refresh(refreshToken string, opts *RefreshOptions) (*RefreshSuccess, error) {
+ if opts != nil && opts.Access != "" {
+ parsed, err := jwt.ParseInsecure([]byte(opts.Access))
+ if err != nil {
+ return nil, ErrInvalidAccessToken
+ }
+ exp, ok := parsed.Expiration()
+ if ok && exp.After(time.Now().Add(time.Second*30)) {
+ return &RefreshSuccess{}, nil
+ }
+ }
+
+ issuerEndpoint := c.issuer + "/token"
+ data := url.Values{}
+ data.Set("grant_type", "refresh_token")
+ data.Set("refresh_token", refreshToken)
+
+ req, err := http.NewRequest("POST", issuerEndpoint, strings.NewReader(data.Encode()))
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to send request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, ErrInvalidRefreshToken
+ }
+
+ var tokens Tokens
+ if err := json.NewDecoder(resp.Body).Decode(&tokens); err != nil {
+ return nil, ErrInvalidRefreshToken
+ }
+
+ return &RefreshSuccess{Tokens: &tokens}, nil
+}
+
+// Verify the token in the incoming request.
+//
+// This is typically used for SSR sites where the token is stored in an HTTP only cookie. And
+// is passed to the server on every request.
+//
+// result, err := client.Verify(token, &VerifyOptions{})
+//
+// This optionally takes the refresh token as well. If passed in, it'll automatically
+// refresh the access token if it has expired.
+//
+// result, err := client.Verify(token, &VerifyOptions{
+// Refresh: refreshToken,
+// })
+//
+// This returns the decoded subjects from the access token. And the tokens if they've been
+// refreshed.
+//
+// if result.Tokens != nil {
+// // tokens are refreshed
+// }
+//
+// Or if it fails, it returns an error that you can handle depending on the error.
+//
+// if err != nil {
+// if errors.Is(err, ErrInvalidRefreshToken) {
+// // handle invalid refresh token error
+// } else {
+// // handle other errors
+// }
+// }
+func (c *Client) Verify(token string, options *VerifyOptions) (*VerifyResult, error) {
+ jwks, err := c.getJWKS()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get JWKS: %w", err)
+ }
+
+ var opts []jwt.ParseOption
+ opts = append(opts, jwt.WithKeySet(jwks))
+ if options.issuer != "" {
+ opts = append(opts, jwt.WithIssuer(options.issuer))
+ }
+ if options.audience != "" {
+ opts = append(opts, jwt.WithAudience(options.audience))
+ }
+
+ parsed, err := jwt.ParseString(token, opts...)
+ if err != nil {
+ // Check if token is expired and we have a refresh token
+ if options.Refresh != "" && errors.Is(err, jwt.TokenExpiredError()) {
+ refreshed, err := c.Refresh(options.Refresh, &RefreshOptions{})
+ if err != nil {
+ return nil, fmt.Errorf("failed to refresh token: %w", err)
+ }
+ if refreshed.Tokens == nil {
+ panic("should have tokens when refreshing without access token")
+ }
+
+ // Recursively verify the new token
+ verified, err := c.Verify(refreshed.Tokens.Access, &VerifyOptions{
+ Refresh: refreshed.Tokens.Refresh,
+ issuer: options.issuer,
+ audience: options.audience,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("failed to verify refreshed token: %w", err)
+ }
+ verified.Tokens = refreshed.Tokens
+ return verified, nil
+ }
+ return nil, fmt.Errorf("failed to parse token: %w", err)
+ }
+
+ // Get standard claims
+ sub, ok := parsed.Subject()
+ if !ok || sub == "" {
+ return nil, errors.New("missing subject")
+ }
+
+ audiences, ok := parsed.Audience()
+ if !ok || len(audiences) == 0 {
+ return nil, errors.New("missing audience")
+ }
+
+ // Get private claims
+ var mode interface{}
+ if err := parsed.Get("mode", &mode); err != nil {
+ return nil, errors.New("missing mode claim")
+ }
+ modeStr, ok := mode.(string)
+ if !ok || modeStr != "access" {
+ return nil, errors.New("invalid token mode")
+ }
+
+ var typ interface{}
+ if err := parsed.Get("type", &typ); err != nil {
+ return nil, errors.New("missing type claim")
+ }
+ typeStr, ok := typ.(string)
+ if !ok {
+ return nil, errors.New("invalid type format")
+ }
+
+ var props interface{}
+ if err := parsed.Get("properties", &props); err != nil {
+ return nil, errors.New("missing properties")
+ }
+ validator, ok := (*c.subjectSchema)[typeStr]
+ if !ok {
+ return nil, errors.New("missing validator for type")
+ }
+ properties, err := validator(props)
+ if err != nil {
+ return nil, err
+ }
+
+ return &VerifyResult{
+ Tokens: nil, // Only set if token was refreshed
+ aud: audiences[0],
+ Subject: &Subject{
+ ID: sub,
+ Type: typeStr,
+ Properties: properties,
+ },
+ }, nil
+}
+
+// Decode a JWT token without verifying its signature.
+//
+// This is typically used for SSR sites where the token is stored in an HTTP only cookie. And
+// is passed to the server on every request.
+//
+// result, err := client.Decode(token)
+//
+// This returns the decoded token's subject if successful.
+//
+// if err != nil {
+// // handle error
+// }
+func (c *Client) Decode(token string) (*DecodeSuccess, error) {
+ parsed, err := jwt.ParseInsecure([]byte(token))
+ if err != nil {
+ return nil, ErrInvalidAccessToken
+ }
+
+ sub, ok := parsed.Subject()
+ if !ok || sub == "" {
+ return nil, ErrInvalidAccessToken
+ }
+
+ var typ interface{}
+ if err := parsed.Get("type", &typ); err != nil {
+ return nil, ErrInvalidAccessToken
+ }
+ typeStr, ok := typ.(string)
+ if !ok {
+ return nil, ErrInvalidAccessToken
+ }
+
+ var props interface{}
+ if err := parsed.Get("properties", &props); err != nil {
+ return nil, ErrInvalidAccessToken
+ }
+ validator, ok := (*c.subjectSchema)[typeStr]
+ if !ok {
+ return nil, errors.New("missing validator for type")
+ }
+ properties, err := validator(props)
+ if err != nil {
+ return nil, err
+ }
+
+ return &DecodeSuccess{
+ Subject: &Subject{ID: sub, Type: typeStr, Properties: properties},
+ }, nil
+}
diff --git a/packages/openauth-go/go.mod b/packages/openauth-go/go.mod
new file mode 100644
index 00000000..82de0357
--- /dev/null
+++ b/packages/openauth-go/go.mod
@@ -0,0 +1,20 @@
+module github.com/toolbeam/openauth
+
+go 1.23.5
+
+require (
+ github.com/google/uuid v1.6.0
+ github.com/lestrrat-go/jwx/v3 v3.0.0
+)
+
+require (
+ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
+ github.com/goccy/go-json v0.10.3 // indirect
+ github.com/lestrrat-go/blackmagic v1.0.2 // indirect
+ github.com/lestrrat-go/httpcc v1.0.1 // indirect
+ github.com/lestrrat-go/httprc/v3 v3.0.0-beta1 // indirect
+ github.com/lestrrat-go/option v1.0.1 // indirect
+ github.com/segmentio/asm v1.2.0 // indirect
+ golang.org/x/crypto v0.36.0 // indirect
+ golang.org/x/sys v0.31.0 // indirect
+)
diff --git a/packages/openauth-go/go.sum b/packages/openauth-go/go.sum
new file mode 100644
index 00000000..58b145d8
--- /dev/null
+++ b/packages/openauth-go/go.sum
@@ -0,0 +1,36 @@
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
+github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
+github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
+github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k=
+github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
+github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
+github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
+github.com/lestrrat-go/httprc/v3 v3.0.0-beta1 h1:pzDjP9dSONCFQC/AE3mWUnHILGiYPiMKzQIS+weKJXA=
+github.com/lestrrat-go/httprc/v3 v3.0.0-beta1/go.mod h1:wdsgouffPvWPEYh8t7PRH/PidR5sfVqt0na4Nhj60Ms=
+github.com/lestrrat-go/jwx/v3 v3.0.0 h1:IRnFNdZx5dJHjTpPVkYqP6TRahJI2Z9v43UwEDJcj6U=
+github.com/lestrrat-go/jwx/v3 v3.0.0/go.mod h1:ak32WoNtHE0aLowVWBcCvXngcAnW4tuC0YhFwOr/kwc=
+github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
+github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
+github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
+golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
+golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
+golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/packages/openauth-go/internal/util/pkce.go b/packages/openauth-go/internal/util/pkce.go
new file mode 100644
index 00000000..7efaa3d1
--- /dev/null
+++ b/packages/openauth-go/internal/util/pkce.go
@@ -0,0 +1,60 @@
+package util
+
+import (
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "errors"
+)
+
+const (
+ PKCEMethodS256 = "S256"
+ PKCEMethodPlain = "plain"
+ PKCEDefaultLength = 64
+)
+
+func generateVerifier(length int) (string, error) {
+ buffer := make([]byte, length)
+ _, err := rand.Read(buffer)
+ if err != nil {
+ return "", err
+ }
+ return base64.URLEncoding.EncodeToString(buffer), nil
+}
+
+func generateChallenge(verifier, method string) (string, error) {
+ if method == PKCEMethodPlain {
+ return verifier, nil
+ }
+ hasher := sha256.New()
+ hasher.Write([]byte(verifier))
+ hash := hasher.Sum(nil)
+ return base64.URLEncoding.EncodeToString(hash), nil
+}
+
+func GeneratePKCE(length ...int) (verifier, challenge, method string, err error) {
+ l := PKCEDefaultLength
+ if len(length) > 0 {
+ l = length[0]
+ }
+ if l < 43 || l > 128 {
+ return "", "", "", errors.New("code verifier length must be between 43 and 128 characters")
+ }
+ verifier, err = generateVerifier(l)
+ if err != nil {
+ return "", "", "", err
+ }
+ challenge, err = generateChallenge(verifier, PKCEMethodS256)
+ if err != nil {
+ return "", "", "", err
+ }
+ return verifier, challenge, PKCEMethodS256, nil
+}
+
+func ValidatePKCE(verifier, challenge, method string) (bool, error) {
+ generatedChallenge, err := generateChallenge(verifier, method)
+ if err != nil {
+ return false, err
+ }
+ return generatedChallenge == challenge, nil
+}
diff --git a/packages/openauth-go/subject/subject.go b/packages/openauth-go/subject/subject.go
new file mode 100644
index 00000000..2e9785c4
--- /dev/null
+++ b/packages/openauth-go/subject/subject.go
@@ -0,0 +1,5 @@
+package subject
+
+type SubjectValidator[T any] func(data any) (T, error)
+
+type SubjectSchemas map[string]SubjectValidator[any]
diff --git a/packages/openauth/script/build.ts b/packages/openauth/script/build.ts
old mode 100644
new mode 100755
diff --git a/packages/openauth/src/client.ts b/packages/openauth/src/client.ts
index c4e282a3..a3f8bf0d 100644
--- a/packages/openauth/src/client.ts
+++ b/packages/openauth/src/client.ts
@@ -116,7 +116,7 @@ export type Challenge = {
/**
* Configure the client.
*/
-export interface ClientInput {
+export interface ClientInput {
/**
* The client ID. This is just a string to identify your app.
*
@@ -141,6 +141,12 @@ export interface ClientInput {
* ```
*/
issuer?: string
+
+ /**
+ * Optionally specify the subjects that are used by the issuer.
+ */
+ subjects?: S
+
/**
* Optionally, override the internally used fetch function.
*
@@ -319,7 +325,13 @@ export interface VerifyResult {
* Has the same shape as the subjects you defined when creating the issuer.
*/
subject: {
- [type in keyof T]: { type: type; properties: v1.InferOutput }
+ [type in keyof T]: {
+ id: string
+ type: type
+ properties: v1.InferOutput extends unknown
+ ? Record
+ : v1.InferOutput
+ }
}[keyof T]
}
@@ -343,7 +355,7 @@ export interface VerifyError {
/**
* An instance of the OpenAuth client contains the following methods.
*/
-export interface Client {
+export interface Client {
/**
* Start the autorization flow. For example, in SSR sites.
*
@@ -532,11 +544,38 @@ export interface Client {
* }
* ```
*/
- verify(
- subjects: T,
+ verify(
token: string,
options?: VerifyOptions,
- ): Promise | VerifyError>
+ ): Promise | VerifyError>
+
+ /**
+ * Decode a JWT token without verifying its signature.
+ *
+ * ```ts
+ * const decoded = client.decode(token, subjects)
+ * ```
+ *
+ * This returns the decoded token's subject if successful.
+ *
+ * ```ts
+ * if (!decoded.err) {
+ * console.log(decoded.subject.properties)
+ * }
+ * ```
+ *
+ * Or if it fails, it returns an error.
+ *
+ * ```ts
+ * if (decoded.err) {
+ * // handle error
+ * }
+ * ```
+ */
+ decode(
+ token: string,
+ subjects: T,
+ ): DecodeSuccess | DecodeError
}
/**
@@ -544,7 +583,37 @@ export interface Client {
*
* @param input - Configure the client.
*/
-export function createClient(input: ClientInput): Client {
+/**
+ * Returned when the decode is successful.
+ */
+export interface DecodeSuccess {
+ /**
+ * This is always `false` when the decode is successful.
+ */
+ err: false
+ /**
+ * The decoded subject from the token.
+ */
+ subject: {
+ id: string
+ type: keyof T
+ properties: v1.InferOutput
+ }
+}
+
+/**
+ * Returned when the decode fails.
+ */
+export interface DecodeError {
+ /**
+ * The type of error that occurred.
+ */
+ err: InvalidAccessTokenError
+}
+
+export function createClient(
+ input: ClientInput,
+): Client {
const jwksCache = new Map>()
const issuerCache = new Map()
const issuer = input.issuer || process.env.OPENAUTH_ISSUER
@@ -695,7 +764,6 @@ export function createClient(input: ClientInput): Client {
}
},
async verify(
- subjects: T,
token: string,
options?: VerifyOptions,
): Promise | VerifyError> {
@@ -708,33 +776,32 @@ export function createClient(input: ClientInput): Client {
}>(token, jwks, {
issuer,
})
- const validated = await subjects[result.payload.type][
- "~standard"
- ].validate(result.payload.properties)
- if (!validated.issues && result.payload.mode === "access")
- return {
- aud: result.payload.aud as string,
- subject: {
- type: result.payload.type,
- properties: validated.value,
- } as any,
+ let properties = result.payload.properties
+ if (input.subjects) {
+ const schema = input.subjects[result.payload.type as keyof S]
+ const validation = await schema["~standard"].validate(properties)
+ if (validation.issues) {
+ throw new InvalidSubjectError()
}
+ properties = validation.value
+ }
return {
- err: new InvalidSubjectError(),
+ aud: result.payload.aud as string,
+ subject: {
+ id: result.payload.sub,
+ type: result.payload.type,
+ properties: properties,
+ } as any,
}
} catch (e) {
if (e instanceof errors.JWTExpired && options?.refresh) {
const refreshed = await this.refresh(options.refresh)
if (refreshed.err) return refreshed
- const verified = await result.verify(
- subjects,
- refreshed.tokens!.access,
- {
- refresh: refreshed.tokens!.refresh,
- issuer,
- fetch: options?.fetch,
- },
- )
+ const verified = await result.verify(refreshed.tokens!.access, {
+ refresh: refreshed.tokens!.refresh,
+ issuer,
+ fetch: options?.fetch,
+ })
if (verified.err) return verified
verified.tokens = refreshed.tokens
return verified
@@ -744,6 +811,34 @@ export function createClient(input: ClientInput): Client {
}
}
},
+
+ decode(
+ token: string,
+ ): DecodeSuccess | DecodeError {
+ try {
+ const payload = decodeJwt(token)
+ if (
+ !payload ||
+ typeof payload.type !== "string" ||
+ typeof payload.sub !== "string"
+ ) {
+ return { err: new InvalidAccessTokenError() }
+ }
+
+ const type = payload.type as keyof T
+
+ return {
+ err: false,
+ subject: {
+ id: payload.sub,
+ type,
+ properties: payload.properties as v1.InferOutput,
+ },
+ }
+ } catch (e) {
+ return { err: new InvalidAccessTokenError() }
+ }
+ },
}
return result
}
diff --git a/packages/openauth/src/issuer.ts b/packages/openauth/src/issuer.ts
index d3bec881..66bb0bfd 100644
--- a/packages/openauth/src/issuer.ts
+++ b/packages/openauth/src/issuer.ts
@@ -152,13 +152,15 @@ export interface OnSuccessResponder<
*/
subject(
type: Type,
- properties: Extract["properties"],
+ id: string,
+ properties: [Extract["properties"]] extends [never]
+ ? Record
+ : Extract["properties"],
opts?: {
ttl?: {
access?: number
refresh?: number
}
- subject?: string
},
): Promise
}
@@ -208,7 +210,7 @@ export const aws = awsHandle
export interface IssuerInput<
Providers extends Record>,
- Subjects extends SubjectSchema,
+ Subjects extends SubjectSchema = SubjectSchema,
Result = {
[key in keyof Providers]: Prettify<
{
@@ -236,7 +238,7 @@ export interface IssuerInput<
* })
* ```
*/
- subjects: Subjects
+ subjects?: Subjects
/**
* The storage adapter that you want to use.
*
@@ -515,18 +517,25 @@ export function issuer<
async success(ctx: Context, properties: any, successOpts) {
return await input.success(
{
- async subject(type, properties, subjectOpts) {
+ async subject(type, id, unvalidated, subjectOpts) {
const authorization = await getAuthorization(ctx)
- const subject = subjectOpts?.subject
- ? subjectOpts.subject
- : await resolveSubject(type, properties)
- await successOpts?.invalidate?.(
- await resolveSubject(type, properties),
- )
+ let properties: any = unvalidated
+ const validator = input.subjects?.[type]
+ if (validator) {
+ const validated =
+ await validator["~standard"].validate(properties)
+ if (validated.issues) {
+ throw new Error(
+ validated.issues.map((i) => i.message).join("\n"),
+ )
+ }
+ properties = validated.value
+ }
+ await successOpts?.invalidate?.(id)
if (authorization.response_type === "token") {
const location = new URL(authorization.redirect_uri)
const tokens = await generateTokens(ctx, {
- subject,
+ subject: id,
type: type as string,
properties,
clientID: authorization.client_id,
@@ -551,7 +560,7 @@ export function issuer<
{
type,
properties,
- subject,
+ subject: id,
redirectURI: authorization.redirect_uri,
clientID: authorization.client_id,
pkce: authorization.pkce,
@@ -634,18 +643,6 @@ export function issuer<
.encrypt(await encryptionKey().then((k) => k.public))
}
- async function resolveSubject(type: string, properties: any) {
- const jsonString = JSON.stringify(properties)
- const encoder = new TextEncoder()
- const data = encoder.encode(jsonString)
- const hashBuffer = await crypto.subtle.digest("SHA-1", data)
- const hashArray = Array.from(new Uint8Array(hashBuffer))
- const hashHex = hashArray
- .map((b) => b.toString(16).padStart(2, "0"))
- .join("")
- return `${type}:${hashHex.slice(0, 16)}`
- }
-
async function generateTokens(
ctx: Context,
value: {
@@ -684,7 +681,8 @@ export function issuer<
value.ttl.refresh,
)
}
- const accessTimeUsed = Math.floor((value.timeUsed ?? Date.now()) / 1000)
+ const expiry =
+ Math.floor((value.timeUsed ?? Date.now()) / 1000) + value.ttl.access
return {
access: await new SignJWT({
mode: "access",
@@ -694,7 +692,7 @@ export function issuer<
iss: issuer(ctx),
sub: value.subject,
})
- .setExpirationTime(Math.floor(accessTimeUsed + value.ttl.access))
+ .setExpirationTime(expiry)
.setProtectedHeader(
await signingKey().then((k) => ({
alg: k.alg,
@@ -703,9 +701,7 @@ export function issuer<
})),
)
.sign(await signingKey().then((item) => item.private)),
- expiresIn: Math.floor(
- accessTimeUsed + value.ttl.access - Date.now() / 1000,
- ),
+ expiresIn: Math.floor(expiry - Date.now() / 1000),
refresh: [value.subject, refreshToken].join(":"),
}
}
@@ -979,11 +975,10 @@ export function issuer<
})
return input.success(
{
- async subject(type, properties, opts) {
+ async subject(type, id, properties, opts) {
const tokens = await generateTokens(c, {
type: type as string,
- subject:
- opts?.subject || (await resolveSubject(type, properties)),
+ subject: id,
properties,
clientID: clientID.toString(),
ttl: {
@@ -1122,18 +1117,20 @@ export function issuer<
issuer: issuer(c),
})
- const validated = await input.subjects[result.payload.type][
- "~standard"
- ].validate(result.payload.properties)
-
- if (!validated.issues && result.payload.mode === "access") {
- return c.json(validated.value as SubjectSchema)
+ let validated = result.payload.properties
+ const schema = input.subjects?.[result.payload.type]
+ if (schema) {
+ const result = await schema["~standard"].validate(validated)
+ if (result.issues) {
+ return c.json({
+ error: "invalid_token",
+ error_description: "Invalid token",
+ })
+ }
+ validated = result.value
}
- return c.json({
- error: "invalid_token",
- error_description: "Invalid token",
- })
+ return c.json(validated as SubjectSchema)
})
app.onError(async (err, c) => {
diff --git a/packages/openauth/test/client.test.ts b/packages/openauth/test/client.test.ts
index a60e3fbc..aa2195b9 100644
--- a/packages/openauth/test/client.test.ts
+++ b/packages/openauth/test/client.test.ts
@@ -9,7 +9,7 @@ import {
afterAll,
mock,
} from "bun:test"
-import { object, string } from "valibot"
+import { object } from "valibot"
import { issuer } from "../src/issuer.js"
import { createClient } from "../src/client.js"
import {
@@ -20,9 +20,7 @@ import { MemoryStorage } from "../src/storage/memory.js"
import { createSubjects } from "../src/subject.js"
const subjects = createSubjects({
- user: object({
- userID: string(),
- }),
+ user: object({}),
})
let storage = MemoryStorage()
@@ -31,9 +29,7 @@ const auth = issuer({
subjects,
allow: async () => true,
success: async (ctx) => {
- return ctx.subject("user", {
- userID: "123",
- })
+ return ctx.subject("user", "123", {})
},
ttl: {
access: 60,
@@ -78,10 +74,11 @@ describe("verify", () => {
clientID: "123",
fetch: (a, b) => Promise.resolve(auth.request(a, b)),
})
- const [verifier, authorization] = await client.pkce(
+ const authorization = await client.authorize(
"https://client.example.com/callback",
+ "code",
)
- let response = await auth.request(authorization)
+ let response = await auth.request(authorization.url)
response = await auth.request(response.headers.get("location")!, {
headers: {
cookie: response.headers.get("set-cookie")!,
@@ -92,7 +89,7 @@ describe("verify", () => {
const exchanged = await client.exchange(
code!,
"https://client.example.com/callback",
- verifier,
+ authorization.challenge.verifier,
)
if (exchanged.err) throw exchanged.err
tokens = exchanged.tokens
@@ -100,14 +97,13 @@ describe("verify", () => {
test("success", async () => {
const refreshSpy = spyOn(client, "refresh")
- const verified = await client.verify(subjects, tokens.access)
+ const verified = await client.verify(tokens.access)
expect(verified).toStrictEqual({
aud: "123",
subject: {
+ id: "123",
type: "user",
- properties: {
- userID: "123",
- },
+ properties: {},
},
})
expect(refreshSpy).not.toBeCalled()
@@ -116,7 +112,7 @@ describe("verify", () => {
test("success after refresh", async () => {
const refreshSpy = spyOn(client, "refresh")
setSystemTime(Date.now() + 1000 * 6000 + 1000)
- const verified = await client.verify(subjects, tokens.access, {
+ const verified = await client.verify(tokens.access, {
refresh: tokens.refresh,
})
expect(verified).toStrictEqual({
@@ -127,10 +123,9 @@ describe("verify", () => {
refresh: expectNonEmptyString,
},
subject: {
+ id: "123",
type: "user",
- properties: {
- userID: "123",
- },
+ properties: {},
},
})
expect(refreshSpy).toBeCalled()
@@ -138,7 +133,7 @@ describe("verify", () => {
test("failure with expired access token", async () => {
setSystemTime(Date.now() + 1000 * 6000 + 1000)
- const verified = await client.verify(subjects, tokens.access)
+ const verified = await client.verify(tokens.access)
expect(verified).toStrictEqual({
err: expect.any(InvalidAccessTokenError),
})
@@ -146,7 +141,7 @@ describe("verify", () => {
test("failure with invalid refresh token", async () => {
setSystemTime(Date.now() + 1000 * 6000 + 1000)
- const verified = await client.verify(subjects, tokens.access, {
+ const verified = await client.verify(tokens.access, {
refresh: "foo",
})
expect(verified).toStrictEqual({
diff --git a/packages/openauth/test/issuer.test.ts b/packages/openauth/test/issuer.test.ts
index be303d77..174aeb21 100644
--- a/packages/openauth/test/issuer.test.ts
+++ b/packages/openauth/test/issuer.test.ts
@@ -14,9 +14,7 @@ import { MemoryStorage } from "../src/storage/memory.js"
import { Provider } from "../src/provider/provider.js"
const subjects = createSubjects({
- user: object({
- userID: string(),
- }),
+ user: object({}),
})
let storage = MemoryStorage()
@@ -52,11 +50,10 @@ const issuerConfig = {
},
success: async (ctx, value) => {
if (value.provider === "dummy") {
- return ctx.subject("user", {
- userID: "123",
+ return ctx.subject("1", {
+ userID: "1",
})
}
- throw new Error("Invalid provider: " + value.provider)
},
}
const auth = issuer(issuerConfig)
@@ -75,6 +72,7 @@ describe("code flow", () => {
test("success", async () => {
const client = createClient({
issuer: "https://auth.example.com",
+ subjects,
clientID: "123",
fetch: (a, b) => Promise.resolve(auth.request(a, b)),
})
@@ -108,13 +106,12 @@ describe("code flow", () => {
refresh: expectNonEmptyString,
expiresIn: 60,
})
- const verified = await client.verify(subjects, tokens.access)
+ const verified = await client.verify(tokens.access)
if (verified.err) throw verified.err
expect(verified.subject).toStrictEqual({
+ id: "123",
type: "user",
- properties: {
- userID: "123",
- },
+ properties: {},
})
})
})
@@ -144,14 +141,13 @@ describe("client credentials flow", () => {
access_token: expectNonEmptyString,
refresh_token: expectNonEmptyString,
})
- const verified = await client.verify(subjects, tokens.access_token)
+ const verified = await client.verify(tokens.access_token)
expect(verified).toStrictEqual({
aud: "myuser",
subject: {
+ id: "123",
type: "user",
- properties: {
- userID: "123",
- },
+ properties: {},
},
})
})
@@ -227,14 +223,13 @@ describe("refresh token", () => {
expect(refreshed.access_token).not.toEqual(tokens.access)
expect(refreshed.refresh_token).not.toEqual(tokens.refresh)
- const verified = await client.verify(subjects, refreshed.access_token)
+ const verified = await client.verify(refreshed.access_token)
expect(verified).toStrictEqual({
aud: "123",
subject: {
+ id: "123",
type: "user",
- properties: {
- userID: "123",
- },
+ properties: {},
},
})
})
@@ -254,14 +249,13 @@ describe("refresh token", () => {
expect(refreshed.access_token).not.toEqual(tokens.access)
expect(refreshed.refresh_token).not.toEqual(tokens.refresh)
- const verified = await client.verify(subjects, refreshed.access_token)
+ const verified = await client.verify(refreshed.access_token)
expect(verified).toStrictEqual({
aud: "123",
subject: {
+ id: "123",
type: "user",
- properties: {
- userID: "123",
- },
+ properties: {},
},
})
})
diff --git a/packages/openauth/test/scrap.test.ts b/packages/openauth/test/scrap.test.ts
deleted file mode 100644
index 61e2d407..00000000
--- a/packages/openauth/test/scrap.test.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-import { expect, test } from "bun:test"
-import { issuer } from "../src/issuer.js"
-import { MemoryStorage } from "../src/storage/memory.js"
-import { object, string } from "valibot"
-import { createSubjects } from "../src/subject.js"
-import { createClient } from "../src/client.js"
-
-const subjects = createSubjects({
- user: object({
- userID: string(),
- }),
-})
-
-const auth = issuer({
- storage: MemoryStorage(),
- subjects,
- allow: async () => true,
- success: async (ctx) => {
- return ctx.subject("user", {
- userID: "123",
- })
- },
- ttl: {
- access: 1,
- },
- providers: {
- dummy: {
- type: "dummy",
- init(route, ctx) {
- route.get("/authorize", async (c) => {
- return ctx.success(c, {
- email: "foo@bar.com",
- })
- })
- },
- },
- },
-})
-
-test("code flow", async () => {
- const client = createClient({
- issuer: "https://auth.example.com",
- clientID: "123",
- fetch: (a, b) => Promise.resolve(auth.request(a, b)),
- })
- const [verifier, authorization] = await client.pkce(
- "https://client.example.com/callback",
- )
- let response = await auth.request(authorization)
- expect(response.status).toBe(302)
- response = await auth.request(response.headers.get("location")!, {
- headers: {
- cookie: response.headers.get("set-cookie")!,
- },
- })
- expect(response.status).toBe(302)
- const location = new URL(response.headers.get("location")!)
- const code = location.searchParams.get("code")
- expect(code).not.toBeNull()
- const exchanged = await client.exchange(
- code!,
- "https://client.example.com/callback",
- verifier,
- )
- if (exchanged.err) throw exchanged.err
- expect(exchanged.tokens.access).toBeTruthy()
- expect(exchanged.tokens.refresh).toBeTruthy()
- const verified = await client.verify(subjects, exchanged.tokens.access)
- if (verified.err) throw verified.err
- expect(verified.subject.type).toBe("user")
- if (verified.subject.type !== "user") throw new Error("Invalid subject")
- expect(verified.subject.properties.userID).toBe("123")
- await new Promise((resolve) => setTimeout(resolve, 2000))
- const failed = await client.verify(subjects, exchanged.tokens.access)
- expect(failed.err).toBeInstanceOf(Error)
- const next = await client.verify(subjects, exchanged.tokens.access, {
- refresh: exchanged.tokens.refresh,
- })
- if (next.err) throw next.err
- expect(next.tokens?.access).toBeDefined()
- expect(next.tokens?.refresh).toBeDefined()
- expect(next.tokens?.access).not.toEqual(exchanged.tokens.access)
- expect(next.tokens?.refresh).not.toEqual(exchanged.tokens.refresh)
- await client.verify(subjects, next.tokens!.access!)
-})
diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md
new file mode 100644
index 00000000..52a57997
--- /dev/null
+++ b/packages/react/CHANGELOG.md
@@ -0,0 +1,7 @@
+# @openauthjs/react
+
+## 0.1.0
+
+### Minor Changes
+
+- Initial release
\ No newline at end of file
diff --git a/packages/react/package.json b/packages/react/package.json
new file mode 100644
index 00000000..465b414b
--- /dev/null
+++ b/packages/react/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "@openauthjs/react",
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "build": "tsc"
+ },
+ "sideEffects": false,
+ "devDependencies": {
+ "@tsconfig/node22": "22.0.0",
+ "@types/react": "^19.0.0",
+ "typescript": "5.6.3"
+ },
+ "exports": {
+ ".": {
+ "import": "./dist/index.js",
+ "types": "./dist/index.d.ts"
+ }
+ },
+ "peerDependencies": {
+ "react": "^19.0.0"
+ },
+ "dependencies": {
+ "@openauthjs/openauth": "workspace:*"
+ },
+ "files": [
+ "src",
+ "dist"
+ ]
+}
+
diff --git a/packages/react/src/index.tsx b/packages/react/src/index.tsx
new file mode 100644
index 00000000..0e452493
--- /dev/null
+++ b/packages/react/src/index.tsx
@@ -0,0 +1,264 @@
+import { createClient } from "@openauthjs/openauth/client"
+import {
+ createContext,
+ useContext,
+ useEffect,
+ useState,
+ ReactNode,
+ useCallback,
+ Dispatch,
+ SetStateAction,
+ useMemo,
+} from "react"
+
+interface Storage {
+ subjects: Record<
+ string,
+ SubjectInfo
+ >
+ current?: string
+}
+
+export interface AuthorizeOptions {
+ redirectPath?: string
+ provider?: string
+}
+
+export interface Context {
+ all: Record
+ subject?: SubjectInfo
+ switch(id: string): void
+ logout(id?: string): void
+ access(id?: string): Promise
+ authorize(opts?: AuthorizeOptions): void
+}
+
+interface SubjectInfo {
+ id: string
+ refresh: string
+}
+
+interface AuthContextOpts {
+ issuer: string
+ clientID: string
+ children: ReactNode
+ onExpiry?: (id: string, ctx: Context) => Promise
+}
+
+
+const AuthContext = createContext(undefined)
+
+const STORAGE_PREFIX = "openauth"
+
+function usePersistedState(key: string, initialState: T): [T, Dispatch>] {
+ const [state, setState] = useState(() => {
+ try {
+ const item = localStorage.getItem(key)
+ return item ? JSON.parse(item) : initialState
+ } catch (error) {
+ console.error('Error reading from localStorage:', error)
+ return initialState
+ }
+ })
+
+ useEffect(() => {
+ try {
+ localStorage.setItem(key, JSON.stringify(state))
+ } catch (error) {
+ console.error('Error writing to localStorage:', error)
+ }
+ }, [key, state])
+
+ useEffect(() => {
+ const handleStorageChange = (event: StorageEvent) => {
+ if (event.key === key && event.newValue !== null) {
+ try {
+ setState(JSON.parse(event.newValue))
+ } catch (error) {
+ console.error('Error parsing storage value:', error)
+ }
+ }
+ }
+ window.addEventListener('storage', handleStorageChange)
+ return () => window.removeEventListener('storage', handleStorageChange)
+ }, [key])
+
+ return [state, setState]
+}
+
+export function OpenAuthProvider(props: AuthContextOpts) {
+ const client = useMemo(() => {
+ return createClient({
+ issuer: props.issuer,
+ clientID: props.clientID,
+ })
+ }, [props.issuer, props.clientID])
+
+ const storageKey = `${props.issuer}.auth`
+ const [storage, setStorage] = usePersistedState(storageKey, { subjects: {} })
+
+ const [initialized, setInitialized] = useState(false)
+
+ useEffect(() => {
+ const handleCode = async () => {
+ const hash = new URLSearchParams(window.location.search.substring(1))
+ const code = hash.get("code")
+ const state = hash.get("state")
+ if (code && state) {
+ const oldState = sessionStorage.getItem(`${STORAGE_PREFIX}.state`)
+ const verifier = sessionStorage.getItem(`${STORAGE_PREFIX}.verifier`)
+ const redirect = sessionStorage.getItem(`${STORAGE_PREFIX}.redirect`)
+ if (redirect && verifier && oldState === state) {
+ const result = await client.exchange(code, redirect, verifier)
+ if (!result.err) {
+ const id = result.tokens.refresh.split(":").slice(0, -1).join(":")
+ setStorage(prevStorage => ({
+ ...prevStorage,
+ subjects: {
+ ...prevStorage.subjects,
+ [id]: {
+ id: id,
+ refresh: result.tokens.refresh,
+ }
+ },
+ current: id
+ }))
+ }
+ }
+ const url = new URL(window.location.href)
+ url.searchParams.delete('code')
+ url.searchParams.delete('state')
+ window.history.replaceState({}, '', url)
+ }
+ setInitialized(true)
+ }
+
+ handleCode()
+ }, [client])
+
+ const authorize = useCallback(async (opts?: AuthorizeOptions) => {
+ const redirect = new URL(
+ window.location.origin + (opts?.redirectPath ?? "/"),
+ ).toString()
+ const authorize = await client.authorize(redirect, "code", {
+ pkce: true,
+ provider: opts?.provider,
+ })
+ sessionStorage.setItem(`${STORAGE_PREFIX}.state`, authorize.challenge.state)
+ sessionStorage.setItem(`${STORAGE_PREFIX}.redirect`, redirect)
+ if (authorize.challenge.verifier)
+ sessionStorage.setItem(`${STORAGE_PREFIX}.verifier`, authorize.challenge.verifier)
+ window.location.href = authorize.url
+ }, [client])
+
+ const accessCache = useMemo(() => new Map(), [])
+ const pendingRequests = useMemo(() => new Map>(), [])
+ const getAccess = useCallback(async (id: string) => {
+ const existingRequest = pendingRequests.get(id)
+ if (existingRequest) {
+ return existingRequest
+ }
+ const request = (async () => {
+ try {
+ const subject = storage.subjects[id]
+ const existing = accessCache.get(id)
+ const access = await client.refresh(subject.refresh, {
+ access: existing,
+ })
+ if (access.err) {
+ ctx.logout(id)
+ if (props.onExpiry) await props.onExpiry(id, ctx)
+ else authorize()
+ return
+ }
+ if (access.tokens) {
+ const tokens = access.tokens
+ setStorage(prev => ({
+ ...prev,
+ subjects: {
+ ...prev.subjects,
+ [id]: {
+ ...prev.subjects[id],
+ refresh: tokens.refresh
+ }
+ }
+ }))
+ accessCache.set(id, tokens.access)
+ return tokens.access
+ }
+ return existing!
+ } finally {
+ pendingRequests.delete(id)
+ }
+ })()
+ pendingRequests.set(id, request)
+ return request
+ }, [client, storage.subjects, accessCache, pendingRequests])
+
+ const ctx: Context = {
+ get all() {
+ return storage.subjects
+ },
+ get subject() {
+ if (!storage.current) return undefined
+ return storage.subjects[storage.current]
+ },
+ switch(id: string) {
+ if (!storage.subjects[id]) return
+ setStorage(prev => ({
+ ...prev,
+ current: id
+ }))
+ },
+ authorize,
+ logout(id?: string) {
+ id = id ?? storage.current
+ if (!id) return
+ if (!storage.subjects[id]) return
+ setStorage(prev => {
+ const newSubjects = { ...prev.subjects }
+ delete newSubjects[id]
+
+ return {
+ ...prev,
+ subjects: newSubjects,
+ current: prev.current === id ? Object.keys(newSubjects)[0] : prev.current
+ }
+ })
+ },
+ async access(id?: string) {
+ const targetId = id || storage.current
+ if (!targetId) return undefined
+ return getAccess(targetId)
+ }
+ }
+
+ useEffect(() => {
+ if (!initialized) return
+ if (storage.current) return
+ const subjects = Object.keys(storage.subjects)
+ if (subjects.length > 0) {
+ setStorage(prev => ({
+ ...prev,
+ current: subjects[0]
+ }))
+ return
+ }
+ }, [initialized, storage.current, storage.subjects])
+
+ if (!initialized) {
+ return null
+ }
+
+ return (
+
+ {props.children}
+
+ )
+}
+
+export function useOpenAuth() {
+ const context = useContext(AuthContext)
+ if (!context) throw new Error("useOpenAuth must be used within an OpenAuthProvider")
+ return context
+}
diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json
new file mode 100644
index 00000000..0b2696d0
--- /dev/null
+++ b/packages/react/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "extends": "@tsconfig/node22/tsconfig.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "declaration": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "target": "ES2022",
+ "lib": [
+ "DOM",
+ "DOM.Iterable",
+ "ES2022"
+ ],
+ "jsx": "react-jsx",
+ "strict": true,
+ "skipLibCheck": true
+ },
+ "include": [
+ "src"
+ ]
+}
\ No newline at end of file
diff --git a/packages/solid/CHANGELOG.md b/packages/solid/CHANGELOG.md
new file mode 100644
index 00000000..c77f7a35
--- /dev/null
+++ b/packages/solid/CHANGELOG.md
@@ -0,0 +1,51 @@
+# @openauthjs/solid
+
+## 0.0.0-20250310005931
+
+### Patch Changes
+
+- Snapshot release 20250309205929
+- Updated dependencies
+ - @openauthjs/openauth@0.0.0-20250310005931
+
+## 0.0.0-20250309205835-20250310005837
+
+### Patch Changes
+
+- Snapshot release 20250309205835
+- Updated dependencies
+ - @openauthjs/openauth@0.0.0-20250309205835-20250310005837
+
+## 0.0.0-20250309184932-20250309224934
+
+### Patch Changes
+
+- Snapshot release 20250309184918
+- Snapshot release 20250309184932
+- Updated dependencies
+- Updated dependencies
+ - @openauthjs/openauth@0.0.0-20250309184932-20250309224934
+
+## 0.0.0-20250309183911-20250309223913
+
+### Patch Changes
+
+- Snapshot release 20250309183911
+- Updated dependencies
+ - @openauthjs/openauth@0.0.0-20250309183911-20250309223913
+
+## 0.0.0-20250309183848-20250309223850
+
+### Patch Changes
+
+- Snapshot release 20250309183848
+- Updated dependencies [ec8ca65]
+- Updated dependencies
+ - @openauthjs/openauth@0.0.0-20250309183848-20250309223850
+
+## 0.0.0-20250309183546.b99be98-20250309223547
+
+### Patch Changes
+
+- Updated dependencies [ec8ca65]
+ - @openauthjs/openauth@0.0.0-20250309183546.b99be98-20250309223547
diff --git a/packages/solid/package.json b/packages/solid/package.json
new file mode 100644
index 00000000..004648f7
--- /dev/null
+++ b/packages/solid/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "@openauthjs/solid",
+ "version": "0.4.2",
+ "type": "module",
+ "scripts": {
+ "build": "tsc"
+ },
+ "sideEffects": false,
+ "devDependencies": {
+ "@tsconfig/node22": "22.0.0",
+ "typescript": "5.6.3"
+ },
+ "exports": {
+ ".": "./dist/index.jsx"
+ },
+ "peerDependencies": {
+ "solid-js": "^1.8.0"
+ },
+ "dependencies": {
+ "@solid-primitives/storage": "^4.3.1",
+ "@openauthjs/openauth": "workspace:*"
+ },
+ "files": [
+ "src",
+ "dist"
+ ]
+}
diff --git a/packages/solid/src/index.tsx b/packages/solid/src/index.tsx
new file mode 100644
index 00000000..22777ae1
--- /dev/null
+++ b/packages/solid/src/index.tsx
@@ -0,0 +1,184 @@
+import { createClient } from "@openauthjs/openauth/client"
+import { makePersisted } from "@solid-primitives/storage"
+import {
+ batch,
+ createContext,
+ createEffect,
+ createSignal,
+ onMount,
+ ParentProps,
+ Show,
+ useContext,
+} from "solid-js"
+import { createStore, produce } from "solid-js/store"
+
+interface Storage {
+ subjects: Record<
+ string,
+ SubjectInfo
+ >
+ current?: string
+}
+
+interface Context {
+ all: Record
+ subject?: SubjectInfo
+ switch(id: string): void
+ logout(id: string): void
+ access(id?: string): Promise
+ authorize(opts?: AuthorizeOptions): void
+}
+
+export interface AuthorizeOptions {
+ redirectPath?: string
+ provider?: string
+}
+
+interface SubjectInfo {
+ id: string
+ refresh: string
+}
+
+interface AuthContextOpts {
+ issuer: string
+ clientID: string
+}
+
+const context = createContext()
+
+export function OpenAuthProvider(props: ParentProps) {
+ const client = createClient({
+ issuer: props.issuer,
+ clientID: props.clientID,
+ })
+ const [storage, setStorage] = makePersisted(
+ createStore({
+ subjects: {},
+ }),
+ {
+ name: `${props.issuer}.auth`,
+ },
+ )
+
+ const [init, setInit] = createSignal(false)
+
+ onMount(async () => {
+ const hash = new URLSearchParams(window.location.search.substring(1))
+ const code = hash.get("code")
+ const state = hash.get("state")
+ if (code && state) {
+ const oldState = sessionStorage.getItem("openauth.state")
+ const verifier = sessionStorage.getItem("openauth.verifier")
+ const redirect = sessionStorage.getItem("openauth.redirect")
+ if (redirect && verifier && oldState === state) {
+ const result = await client.exchange(code, redirect, verifier)
+ if (!result.err) {
+ const id = result.tokens.refresh.split(":").slice(0, -1).join(":")
+ batch(() => {
+ setStorage("subjects", id, {
+ id: id,
+ refresh: result.tokens.refresh,
+ })
+ setStorage("current", id)
+ })
+ }
+ }
+ }
+ setInit(true)
+ })
+
+ async function authorize(opts?: AuthorizeOptions) {
+ const redirect = new URL(
+ window.location.origin + (opts?.redirectPath ?? "/"),
+ ).toString()
+ const authorize = await client.authorize(redirect, "code", {
+ pkce: true,
+ provider: opts?.provider,
+ })
+ sessionStorage.setItem("openauth.state", authorize.challenge.state)
+ sessionStorage.setItem("openauth.redirect", redirect)
+ if (authorize.challenge.verifier)
+ sessionStorage.setItem("openauth.verifier", authorize.challenge.verifier)
+ window.location.href = authorize.url
+ }
+
+ const accessCache = new Map()
+ const pendingRequests = new Map>()
+ async function access(id: string) {
+ const pending = pendingRequests.get(id)
+ if (pending) return pending
+ const promise = (async () => {
+ const existing = accessCache.get(id)
+ const subject = storage.subjects[id]
+ const access = await client.refresh(subject.refresh, {
+ access: existing,
+ })
+ if (access.err) {
+ pendingRequests.delete(id)
+ ctx.logout(id)
+ return
+ }
+ if (access.tokens) {
+ setStorage("subjects", id, "refresh", access.tokens.refresh)
+ accessCache.set(id, access.tokens.access)
+ }
+ pendingRequests.delete(id)
+ return access.tokens?.access || existing!
+ })()
+ pendingRequests.set(id, promise)
+ return promise
+ }
+
+
+ const ctx: Context = {
+ get all() {
+ return storage.subjects
+ },
+ get subject() {
+ if (!storage.current) return
+ return storage.subjects[storage.current!]
+ },
+ switch(id: string) {
+ if (!storage.subjects[id]) return
+ setStorage("current", id)
+ },
+ authorize,
+ logout(id: string) {
+ if (!storage.subjects[id]) return
+ setStorage(
+ produce((s) => {
+ delete s.subjects[id]
+ if (s.current === id) s.current = Object.keys(s.subjects)[0]
+ }),
+ )
+ },
+ async access(id?: string) {
+ id = id || storage.current
+ if (!id) return
+ return access(id || storage.current!)
+ }
+ }
+
+ createEffect(() => {
+ if (!init()) return
+ if (storage.current) return
+ const [first] = Object.keys(storage.subjects)
+ if (first) {
+ setStorage("current", first)
+ return
+ }
+ })
+
+ return (
+
+ {props.children}
+
+ )
+}
+
+export function useOpenAuth() {
+ const result = useContext(context)
+ if (!result) throw new Error("no auth context")
+ return result
+}
+
diff --git a/packages/solid/tsconfig.json b/packages/solid/tsconfig.json
new file mode 100644
index 00000000..29492476
--- /dev/null
+++ b/packages/solid/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "extends": "@tsconfig/node22/tsconfig.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "declaration": true,
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "target": "ES2022",
+ "lib": [
+ "DOM",
+ "DOM.Iterable",
+ "ES2022"
+ ],
+ "jsx": "preserve",
+ "jsxImportSource": "solid-js",
+ "strict": true,
+ "skipLibCheck": true
+ },
+ "include": [
+ "src"
+ ]
+}
diff --git a/scripts/snapshot b/scripts/snapshot
new file mode 100755
index 00000000..78e71845
--- /dev/null
+++ b/scripts/snapshot
@@ -0,0 +1,35 @@
+#!/usr/bin/env bash
+
+set -e
+
+SNAPSHOT_ID=$(date +%Y%m%d%H%M%S)
+
+echo "Creating snapshot release: ${SNAPSHOT_ID}"
+
+# First build the packages
+echo "Building packages..."
+(cd packages/openauth && bun run build)
+(cd packages/solid && bun run build)
+(cd packages/react && bun run build)
+
+# Use changesets to create a snapshot release
+echo "Creating snapshot release with ID: ${SNAPSHOT_ID}"
+bun changeset version --snapshot
+
+# Fix workspace protocol in solid package that changesets replaces
+echo "Fixing workspace protocol reference..."
+sed -i 's/"@openauthjs\/openauth": "[^"]*"/"@openauthjs\/openauth": "workspace:*"/g' packages/solid/package.json
+
+# Publish the snapshot versions
+echo "Publishing snapshot versions..."
+(cd packages/openauth && bun publish --tag snapshot)
+(cd packages/solid && bun publish --tag snapshot)
+(cd packages/react && bun publish --tag snapshot)
+
+
+# Reset versions in package.json files after snapshot publish
+echo "Resetting package versions..."
+git checkout .
+
+
+echo "Snapshot release completed: ${SNAPSHOT_ID}"