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