@_mustachio/openauth 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/client.js +186 -0
- package/dist/esm/css.d.js +0 -0
- package/dist/esm/error.js +73 -0
- package/dist/esm/index.js +14 -0
- package/dist/esm/issuer.js +558 -0
- package/dist/esm/jwt.js +16 -0
- package/dist/esm/keys.js +113 -0
- package/dist/esm/pkce.js +35 -0
- package/dist/esm/provider/apple.js +28 -0
- package/dist/esm/provider/arctic.js +43 -0
- package/dist/esm/provider/code.js +58 -0
- package/dist/esm/provider/cognito.js +16 -0
- package/dist/esm/provider/discord.js +15 -0
- package/dist/esm/provider/facebook.js +24 -0
- package/dist/esm/provider/github.js +15 -0
- package/dist/esm/provider/google.js +25 -0
- package/dist/esm/provider/index.js +3 -0
- package/dist/esm/provider/jumpcloud.js +15 -0
- package/dist/esm/provider/keycloak.js +15 -0
- package/dist/esm/provider/linkedin.js +15 -0
- package/dist/esm/provider/m2m.js +17 -0
- package/dist/esm/provider/microsoft.js +24 -0
- package/dist/esm/provider/oauth2.js +119 -0
- package/dist/esm/provider/oidc.js +69 -0
- package/dist/esm/provider/passkey.js +315 -0
- package/dist/esm/provider/password.js +306 -0
- package/dist/esm/provider/provider.js +10 -0
- package/dist/esm/provider/slack.js +15 -0
- package/dist/esm/provider/spotify.js +15 -0
- package/dist/esm/provider/twitch.js +15 -0
- package/dist/esm/provider/x.js +16 -0
- package/dist/esm/provider/yahoo.js +15 -0
- package/dist/esm/random.js +27 -0
- package/dist/esm/storage/aws.js +39 -0
- package/dist/esm/storage/cloudflare.js +42 -0
- package/dist/esm/storage/dynamo.js +116 -0
- package/dist/esm/storage/memory.js +88 -0
- package/dist/esm/storage/storage.js +36 -0
- package/dist/esm/subject.js +7 -0
- package/dist/esm/ui/base.js +407 -0
- package/dist/esm/ui/code.js +151 -0
- package/dist/esm/ui/form.js +43 -0
- package/dist/esm/ui/icon.js +92 -0
- package/dist/esm/ui/passkey.js +329 -0
- package/dist/esm/ui/password.js +338 -0
- package/dist/esm/ui/select.js +187 -0
- package/dist/esm/ui/theme.js +115 -0
- package/dist/esm/util.js +54 -0
- package/dist/types/client.d.ts +466 -0
- package/dist/types/client.d.ts.map +1 -0
- package/dist/types/error.d.ts +77 -0
- package/dist/types/error.d.ts.map +1 -0
- package/dist/types/index.d.ts +20 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/issuer.d.ts +465 -0
- package/dist/types/issuer.d.ts.map +1 -0
- package/dist/types/jwt.d.ts +6 -0
- package/dist/types/jwt.d.ts.map +1 -0
- package/dist/types/keys.d.ts +18 -0
- package/dist/types/keys.d.ts.map +1 -0
- package/dist/types/pkce.d.ts +7 -0
- package/dist/types/pkce.d.ts.map +1 -0
- package/dist/types/provider/apple.d.ts +108 -0
- package/dist/types/provider/apple.d.ts.map +1 -0
- package/dist/types/provider/arctic.d.ts +16 -0
- package/dist/types/provider/arctic.d.ts.map +1 -0
- package/dist/types/provider/code.d.ts +74 -0
- package/dist/types/provider/code.d.ts.map +1 -0
- package/dist/types/provider/cognito.d.ts +64 -0
- package/dist/types/provider/cognito.d.ts.map +1 -0
- package/dist/types/provider/discord.d.ts +38 -0
- package/dist/types/provider/discord.d.ts.map +1 -0
- package/dist/types/provider/facebook.d.ts +74 -0
- package/dist/types/provider/facebook.d.ts.map +1 -0
- package/dist/types/provider/github.d.ts +38 -0
- package/dist/types/provider/github.d.ts.map +1 -0
- package/dist/types/provider/google.d.ts +74 -0
- package/dist/types/provider/google.d.ts.map +1 -0
- package/dist/types/provider/index.d.ts +4 -0
- package/dist/types/provider/index.d.ts.map +1 -0
- package/dist/types/provider/jumpcloud.d.ts +38 -0
- package/dist/types/provider/jumpcloud.d.ts.map +1 -0
- package/dist/types/provider/keycloak.d.ts +67 -0
- package/dist/types/provider/keycloak.d.ts.map +1 -0
- package/dist/types/provider/linkedin.d.ts +6 -0
- package/dist/types/provider/linkedin.d.ts.map +1 -0
- package/dist/types/provider/m2m.d.ts +34 -0
- package/dist/types/provider/m2m.d.ts.map +1 -0
- package/dist/types/provider/microsoft.d.ts +89 -0
- package/dist/types/provider/microsoft.d.ts.map +1 -0
- package/dist/types/provider/oauth2.d.ts +133 -0
- package/dist/types/provider/oauth2.d.ts.map +1 -0
- package/dist/types/provider/oidc.d.ts +91 -0
- package/dist/types/provider/oidc.d.ts.map +1 -0
- package/dist/types/provider/passkey.d.ts +143 -0
- package/dist/types/provider/passkey.d.ts.map +1 -0
- package/dist/types/provider/password.d.ts +210 -0
- package/dist/types/provider/password.d.ts.map +1 -0
- package/dist/types/provider/provider.d.ts +29 -0
- package/dist/types/provider/provider.d.ts.map +1 -0
- package/dist/types/provider/slack.d.ts +59 -0
- package/dist/types/provider/slack.d.ts.map +1 -0
- package/dist/types/provider/spotify.d.ts +38 -0
- package/dist/types/provider/spotify.d.ts.map +1 -0
- package/dist/types/provider/twitch.d.ts +38 -0
- package/dist/types/provider/twitch.d.ts.map +1 -0
- package/dist/types/provider/x.d.ts +38 -0
- package/dist/types/provider/x.d.ts.map +1 -0
- package/dist/types/provider/yahoo.d.ts +38 -0
- package/dist/types/provider/yahoo.d.ts.map +1 -0
- package/dist/types/random.d.ts +3 -0
- package/dist/types/random.d.ts.map +1 -0
- package/dist/types/storage/aws.d.ts +4 -0
- package/dist/types/storage/aws.d.ts.map +1 -0
- package/dist/types/storage/cloudflare.d.ts +34 -0
- package/dist/types/storage/cloudflare.d.ts.map +1 -0
- package/dist/types/storage/dynamo.d.ts +65 -0
- package/dist/types/storage/dynamo.d.ts.map +1 -0
- package/dist/types/storage/memory.d.ts +49 -0
- package/dist/types/storage/memory.d.ts.map +1 -0
- package/dist/types/storage/storage.d.ts +15 -0
- package/dist/types/storage/storage.d.ts.map +1 -0
- package/dist/types/subject.d.ts +122 -0
- package/dist/types/subject.d.ts.map +1 -0
- package/dist/types/ui/base.d.ts +5 -0
- package/dist/types/ui/base.d.ts.map +1 -0
- package/dist/types/ui/code.d.ts +104 -0
- package/dist/types/ui/code.d.ts.map +1 -0
- package/dist/types/ui/form.d.ts +6 -0
- package/dist/types/ui/form.d.ts.map +1 -0
- package/dist/types/ui/icon.d.ts +6 -0
- package/dist/types/ui/icon.d.ts.map +1 -0
- package/dist/types/ui/passkey.d.ts +5 -0
- package/dist/types/ui/passkey.d.ts.map +1 -0
- package/dist/types/ui/password.d.ts +139 -0
- package/dist/types/ui/password.d.ts.map +1 -0
- package/dist/types/ui/select.d.ts +55 -0
- package/dist/types/ui/select.d.ts.map +1 -0
- package/dist/types/ui/theme.d.ts +207 -0
- package/dist/types/ui/theme.d.ts.map +1 -0
- package/dist/types/util.d.ts +8 -0
- package/dist/types/util.d.ts.map +1 -0
- package/package.json +51 -0
- package/src/client.ts +749 -0
- package/src/css.d.ts +4 -0
- package/src/error.ts +120 -0
- package/src/index.ts +26 -0
- package/src/issuer.ts +1302 -0
- package/src/jwt.ts +17 -0
- package/src/keys.ts +139 -0
- package/src/pkce.ts +40 -0
- package/src/provider/apple.ts +127 -0
- package/src/provider/arctic.ts +66 -0
- package/src/provider/code.ts +227 -0
- package/src/provider/cognito.ts +74 -0
- package/src/provider/discord.ts +45 -0
- package/src/provider/facebook.ts +84 -0
- package/src/provider/github.ts +45 -0
- package/src/provider/google.ts +85 -0
- package/src/provider/index.ts +3 -0
- package/src/provider/jumpcloud.ts +45 -0
- package/src/provider/keycloak.ts +75 -0
- package/src/provider/linkedin.ts +12 -0
- package/src/provider/m2m.ts +56 -0
- package/src/provider/microsoft.ts +100 -0
- package/src/provider/oauth2.ts +297 -0
- package/src/provider/oidc.ts +179 -0
- package/src/provider/passkey.ts +655 -0
- package/src/provider/password.ts +672 -0
- package/src/provider/provider.ts +33 -0
- package/src/provider/slack.ts +67 -0
- package/src/provider/spotify.ts +45 -0
- package/src/provider/twitch.ts +45 -0
- package/src/provider/x.ts +46 -0
- package/src/provider/yahoo.ts +45 -0
- package/src/random.ts +24 -0
- package/src/storage/aws.ts +59 -0
- package/src/storage/cloudflare.ts +77 -0
- package/src/storage/dynamo.ts +193 -0
- package/src/storage/memory.ts +135 -0
- package/src/storage/storage.ts +46 -0
- package/src/subject.ts +130 -0
- package/src/ui/base.tsx +118 -0
- package/src/ui/code.tsx +215 -0
- package/src/ui/form.tsx +40 -0
- package/src/ui/icon.tsx +95 -0
- package/src/ui/passkey.tsx +321 -0
- package/src/ui/password.tsx +405 -0
- package/src/ui/select.tsx +221 -0
- package/src/ui/theme.ts +319 -0
- package/src/ui/ui.css +252 -0
- package/src/util.ts +58 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configure OpenAuth to use a simple in-memory store.
|
|
3
|
+
*
|
|
4
|
+
* :::caution
|
|
5
|
+
* This is not meant to be used in production.
|
|
6
|
+
* :::
|
|
7
|
+
*
|
|
8
|
+
* This is useful for testing and development. It's not meant to be used in production.
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { MemoryStorage } from "@openauthjs/openauth/storage/memory"
|
|
12
|
+
*
|
|
13
|
+
* const storage = MemoryStorage()
|
|
14
|
+
*
|
|
15
|
+
* export default issuer({
|
|
16
|
+
* storage,
|
|
17
|
+
* // ...
|
|
18
|
+
* })
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* Optionally, you can persist the store to a file.
|
|
22
|
+
*
|
|
23
|
+
* ```ts
|
|
24
|
+
* MemoryStorage({
|
|
25
|
+
* persist: "./persist.json"
|
|
26
|
+
* })
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* @packageDocumentation
|
|
30
|
+
*/
|
|
31
|
+
import { joinKey, splitKey, StorageAdapter } from "./storage.js"
|
|
32
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
33
|
+
import { writeFile } from "node:fs/promises"
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Configure the memory store.
|
|
37
|
+
*/
|
|
38
|
+
export interface MemoryStorageOptions {
|
|
39
|
+
/**
|
|
40
|
+
* Optionally, backup the store to a file. So it'll be persisted when the issuer restarts.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```ts
|
|
44
|
+
* {
|
|
45
|
+
* persist: "./persist.json"
|
|
46
|
+
* }
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
persist?: string
|
|
50
|
+
}
|
|
51
|
+
export function MemoryStorage(input?: MemoryStorageOptions): StorageAdapter {
|
|
52
|
+
const store = [] as [
|
|
53
|
+
string,
|
|
54
|
+
{ value: Record<string, any>; expiry?: number },
|
|
55
|
+
][]
|
|
56
|
+
|
|
57
|
+
if (input?.persist) {
|
|
58
|
+
if (existsSync(input.persist)) {
|
|
59
|
+
const file = readFileSync(input?.persist)
|
|
60
|
+
store.push(...JSON.parse(file.toString()))
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function save() {
|
|
65
|
+
if (!input?.persist) return
|
|
66
|
+
const file = JSON.stringify(store)
|
|
67
|
+
await writeFile(input.persist, file)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function search(key: string) {
|
|
71
|
+
let left = 0
|
|
72
|
+
let right = store.length - 1
|
|
73
|
+
while (left <= right) {
|
|
74
|
+
const mid = Math.floor((left + right) / 2)
|
|
75
|
+
const comparison = key.localeCompare(store[mid][0])
|
|
76
|
+
|
|
77
|
+
if (comparison === 0) {
|
|
78
|
+
return { found: true, index: mid }
|
|
79
|
+
} else if (comparison < 0) {
|
|
80
|
+
right = mid - 1
|
|
81
|
+
} else {
|
|
82
|
+
left = mid + 1
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { found: false, index: left }
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
async get(key: string[]) {
|
|
89
|
+
const match = search(joinKey(key))
|
|
90
|
+
if (!match.found) return undefined
|
|
91
|
+
const entry = store[match.index][1]
|
|
92
|
+
if (entry.expiry && Date.now() >= entry.expiry) {
|
|
93
|
+
store.splice(match.index, 1)
|
|
94
|
+
await save()
|
|
95
|
+
return undefined
|
|
96
|
+
}
|
|
97
|
+
return entry.value
|
|
98
|
+
},
|
|
99
|
+
async set(key: string[], value: any, expiry?: Date) {
|
|
100
|
+
const joined = joinKey(key)
|
|
101
|
+
const match = search(joined)
|
|
102
|
+
// Handle both Date objects and TTL numbers while maintaining Date type in signature
|
|
103
|
+
const entry = [
|
|
104
|
+
joined,
|
|
105
|
+
{
|
|
106
|
+
value,
|
|
107
|
+
expiry: expiry ? expiry.getTime() : expiry,
|
|
108
|
+
},
|
|
109
|
+
] as (typeof store)[number]
|
|
110
|
+
if (!match.found) {
|
|
111
|
+
store.splice(match.index, 0, entry)
|
|
112
|
+
} else {
|
|
113
|
+
store[match.index] = entry
|
|
114
|
+
}
|
|
115
|
+
await save()
|
|
116
|
+
},
|
|
117
|
+
async remove(key: string[]) {
|
|
118
|
+
const joined = joinKey(key)
|
|
119
|
+
const match = search(joined)
|
|
120
|
+
if (match.found) {
|
|
121
|
+
store.splice(match.index, 1)
|
|
122
|
+
await save()
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
async *scan(prefix: string[]) {
|
|
126
|
+
const now = Date.now()
|
|
127
|
+
const prefixStr = joinKey(prefix)
|
|
128
|
+
for (const [key, entry] of store) {
|
|
129
|
+
if (!key.startsWith(prefixStr)) continue
|
|
130
|
+
if (entry.expiry && now >= entry.expiry) continue
|
|
131
|
+
yield [splitKey(key), entry.value]
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export interface StorageAdapter {
|
|
2
|
+
get(key: string[]): Promise<Record<string, any> | undefined>
|
|
3
|
+
remove(key: string[]): Promise<void>
|
|
4
|
+
set(key: string[], value: any, expiry?: Date): Promise<void>
|
|
5
|
+
scan(prefix: string[]): AsyncIterable<[string[], any]>
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const SEPERATOR = String.fromCharCode(0x1f)
|
|
9
|
+
|
|
10
|
+
export function joinKey(key: string[]) {
|
|
11
|
+
return key.join(SEPERATOR)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function splitKey(key: string) {
|
|
15
|
+
return key.split(SEPERATOR)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export namespace Storage {
|
|
19
|
+
function encode(key: string[]) {
|
|
20
|
+
return key.map((k) => k.replaceAll(SEPERATOR, ""))
|
|
21
|
+
}
|
|
22
|
+
export function get<T>(adapter: StorageAdapter, key: string[]) {
|
|
23
|
+
return adapter.get(encode(key)) as Promise<T | null>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function set(
|
|
27
|
+
adapter: StorageAdapter,
|
|
28
|
+
key: string[],
|
|
29
|
+
value: any,
|
|
30
|
+
ttl?: number,
|
|
31
|
+
) {
|
|
32
|
+
const expiry = ttl ? new Date(Date.now() + ttl * 1000) : undefined
|
|
33
|
+
return adapter.set(encode(key), value, expiry)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function remove(adapter: StorageAdapter, key: string[]) {
|
|
37
|
+
return adapter.remove(encode(key))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function scan<T>(
|
|
41
|
+
adapter: StorageAdapter,
|
|
42
|
+
key: string[],
|
|
43
|
+
): AsyncIterable<[string[], T]> {
|
|
44
|
+
return adapter.scan(encode(key))
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/subject.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subjects are what the access token generated at the end of the auth flow will map to. Under
|
|
3
|
+
* the hood, the access token is a JWT that contains this data.
|
|
4
|
+
*
|
|
5
|
+
* #### Define subjects
|
|
6
|
+
*
|
|
7
|
+
* ```ts title="subjects.ts"
|
|
8
|
+
* import { object, string } from "valibot"
|
|
9
|
+
*
|
|
10
|
+
* const subjects = createSubjects({
|
|
11
|
+
* user: object({
|
|
12
|
+
* userID: string()
|
|
13
|
+
* })
|
|
14
|
+
* })
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* We are using [valibot](https://github.com/fabian-hiller/valibot) here. You can use any
|
|
18
|
+
* validation library that's following the
|
|
19
|
+
* [standard-schema specification](https://github.com/standard-schema/standard-schema).
|
|
20
|
+
*
|
|
21
|
+
* :::tip
|
|
22
|
+
* You typically want to place subjects in its own file so it can be imported by all of your apps.
|
|
23
|
+
* :::
|
|
24
|
+
*
|
|
25
|
+
* You can start with one subject. Later you can add more for different types of users.
|
|
26
|
+
*
|
|
27
|
+
* #### Set the subjects
|
|
28
|
+
*
|
|
29
|
+
* Then you can pass it to the `issuer`.
|
|
30
|
+
*
|
|
31
|
+
* ```ts title="issuer.ts"
|
|
32
|
+
* import { subjects } from "./subjects"
|
|
33
|
+
*
|
|
34
|
+
* const app = issuer({
|
|
35
|
+
* providers: { ... },
|
|
36
|
+
* subjects,
|
|
37
|
+
* // ...
|
|
38
|
+
* })
|
|
39
|
+
* ```
|
|
40
|
+
*
|
|
41
|
+
* #### Add the subject payload
|
|
42
|
+
*
|
|
43
|
+
* When your user completes the flow, you can add the subject payload in the `success` callback.
|
|
44
|
+
*
|
|
45
|
+
* ```ts title="issuer.ts"
|
|
46
|
+
* const app = issuer({
|
|
47
|
+
* providers: { ... },
|
|
48
|
+
* subjects,
|
|
49
|
+
* async success(ctx, value) {
|
|
50
|
+
* let userID
|
|
51
|
+
* if (value.provider === "password") {
|
|
52
|
+
* console.log(value.email)
|
|
53
|
+
* userID = ... // lookup user or create them
|
|
54
|
+
* }
|
|
55
|
+
* return ctx.subject("user", {
|
|
56
|
+
* userID
|
|
57
|
+
* })
|
|
58
|
+
* },
|
|
59
|
+
* // ...
|
|
60
|
+
* })
|
|
61
|
+
* ```
|
|
62
|
+
*
|
|
63
|
+
* Here we are looking up the userID from our database and adding it to the subject payload.
|
|
64
|
+
*
|
|
65
|
+
* :::caution
|
|
66
|
+
* You should only store properties that won't change for the lifetime of the user.
|
|
67
|
+
* :::
|
|
68
|
+
*
|
|
69
|
+
* Since these will be stored in the access token, you should avoid storing information
|
|
70
|
+
* that'll change often. For example, if you store the user's username, you'll need to
|
|
71
|
+
* revoke the access token when the user changes their username.
|
|
72
|
+
*
|
|
73
|
+
* #### Decode the subject
|
|
74
|
+
*
|
|
75
|
+
* Now when your user logs in, you can use the OpenAuth client to decode the subject. For
|
|
76
|
+
* example, in our SSR app we can do the following.
|
|
77
|
+
*
|
|
78
|
+
* ```ts title="app/page.tsx"
|
|
79
|
+
* import { subjects } from "../subjects"
|
|
80
|
+
*
|
|
81
|
+
* const verified = await client.verify(subjects, cookies.get("access_token")!)
|
|
82
|
+
* console.log(verified.subject.properties.userID)
|
|
83
|
+
* ```
|
|
84
|
+
*
|
|
85
|
+
* All this is typesafe based on the shape of the subjects you defined.
|
|
86
|
+
*
|
|
87
|
+
* @packageDocumentation
|
|
88
|
+
*/
|
|
89
|
+
import type { v1 } from "@standard-schema/spec"
|
|
90
|
+
import { Prettify } from "./util.js"
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Subject schema is a map of types that are used to define the subjects.
|
|
94
|
+
*/
|
|
95
|
+
export type SubjectSchema = Record<string, v1.StandardSchema>
|
|
96
|
+
|
|
97
|
+
/** @internal */
|
|
98
|
+
export type SubjectPayload<T extends SubjectSchema> = Prettify<
|
|
99
|
+
{
|
|
100
|
+
[type in keyof T & string]: {
|
|
101
|
+
type: type
|
|
102
|
+
properties: v1.InferOutput<T[type]>
|
|
103
|
+
}
|
|
104
|
+
}[keyof T & string]
|
|
105
|
+
>
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Create a subject schema.
|
|
109
|
+
*
|
|
110
|
+
* @example
|
|
111
|
+
* ```ts
|
|
112
|
+
* const subjects = createSubjects({
|
|
113
|
+
* user: object({
|
|
114
|
+
* userID: string()
|
|
115
|
+
* }),
|
|
116
|
+
* admin: object({
|
|
117
|
+
* workspaceID: string()
|
|
118
|
+
* })
|
|
119
|
+
* })
|
|
120
|
+
* ```
|
|
121
|
+
*
|
|
122
|
+
* This is using [valibot](https://github.com/fabian-hiller/valibot) to define the shape of the
|
|
123
|
+
* subjects. You can use any validation library that's following the
|
|
124
|
+
* [standard-schema specification](https://github.com/standard-schema/standard-schema).
|
|
125
|
+
*/
|
|
126
|
+
export function createSubjects<Schema extends SubjectSchema = {}>(
|
|
127
|
+
types: Schema,
|
|
128
|
+
): Schema {
|
|
129
|
+
return { ...types }
|
|
130
|
+
}
|
package/src/ui/base.tsx
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { PropsWithChildren } from "hono/jsx"
|
|
2
|
+
import css from "./ui.css" assert { type: "text" }
|
|
3
|
+
import { getTheme } from "./theme.js"
|
|
4
|
+
|
|
5
|
+
export function Layout(
|
|
6
|
+
props: PropsWithChildren<{
|
|
7
|
+
size?: "small"
|
|
8
|
+
}>,
|
|
9
|
+
) {
|
|
10
|
+
const theme = getTheme()
|
|
11
|
+
function get(key: "primary" | "background" | "logo", mode: "light" | "dark") {
|
|
12
|
+
if (!theme) return
|
|
13
|
+
if (!theme[key]) return
|
|
14
|
+
if (typeof theme[key] === "string") return theme[key]
|
|
15
|
+
|
|
16
|
+
return theme[key][mode] as string | undefined
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const radius = (() => {
|
|
20
|
+
if (theme?.radius === "none") return "0"
|
|
21
|
+
if (theme?.radius === "sm") return "1"
|
|
22
|
+
if (theme?.radius === "md") return "1.25"
|
|
23
|
+
if (theme?.radius === "lg") return "1.5"
|
|
24
|
+
if (theme?.radius === "full") return "1000000000001"
|
|
25
|
+
return "1"
|
|
26
|
+
})()
|
|
27
|
+
|
|
28
|
+
const hasLogo = get("logo", "light") && get("logo", "dark")
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<html
|
|
32
|
+
style={{
|
|
33
|
+
"--color-background-light": get("background", "light"),
|
|
34
|
+
"--color-background-dark": get("background", "dark"),
|
|
35
|
+
"--color-primary-light": get("primary", "light"),
|
|
36
|
+
"--color-primary-dark": get("primary", "dark"),
|
|
37
|
+
"--font-family": theme?.font?.family,
|
|
38
|
+
"--font-scale": theme?.font?.scale,
|
|
39
|
+
"--border-radius": radius,
|
|
40
|
+
}}
|
|
41
|
+
>
|
|
42
|
+
<head>
|
|
43
|
+
<title>{theme?.title || "OpenAuthJS"}</title>
|
|
44
|
+
<meta charset="utf-8" />
|
|
45
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
46
|
+
{theme?.favicon ? (
|
|
47
|
+
<link rel="icon" href={theme?.favicon} />
|
|
48
|
+
) : (
|
|
49
|
+
<>
|
|
50
|
+
<link
|
|
51
|
+
rel="icon"
|
|
52
|
+
href="https://openauth.js.org/favicon.ico"
|
|
53
|
+
sizes="48x48"
|
|
54
|
+
/>
|
|
55
|
+
<link
|
|
56
|
+
rel="icon"
|
|
57
|
+
href="https://openauth.js.org/favicon.svg"
|
|
58
|
+
media="(prefers-color-scheme: light)"
|
|
59
|
+
/>
|
|
60
|
+
<link
|
|
61
|
+
rel="icon"
|
|
62
|
+
href="https://openauth.js.org/favicon-dark.svg"
|
|
63
|
+
media="(prefers-color-scheme: dark)"
|
|
64
|
+
/>
|
|
65
|
+
<link
|
|
66
|
+
rel="shortcut icon"
|
|
67
|
+
href="https://openauth.js.org/favicon.svg"
|
|
68
|
+
type="image/svg+xml"
|
|
69
|
+
/>
|
|
70
|
+
</>
|
|
71
|
+
)}
|
|
72
|
+
<style dangerouslySetInnerHTML={{ __html: css }} />
|
|
73
|
+
{theme?.css && (
|
|
74
|
+
<style dangerouslySetInnerHTML={{ __html: theme.css }} />
|
|
75
|
+
)}
|
|
76
|
+
</head>
|
|
77
|
+
<body>
|
|
78
|
+
<div data-component="root">
|
|
79
|
+
<div data-component="center" data-size={props.size}>
|
|
80
|
+
{hasLogo ? (
|
|
81
|
+
<>
|
|
82
|
+
<img
|
|
83
|
+
data-component="logo"
|
|
84
|
+
src={get("logo", "light")}
|
|
85
|
+
data-mode="light"
|
|
86
|
+
/>
|
|
87
|
+
<img
|
|
88
|
+
data-component="logo"
|
|
89
|
+
src={get("logo", "dark")}
|
|
90
|
+
data-mode="dark"
|
|
91
|
+
/>
|
|
92
|
+
</>
|
|
93
|
+
) : (
|
|
94
|
+
ICON_OPENAUTH
|
|
95
|
+
)}
|
|
96
|
+
{props.children}
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
</body>
|
|
100
|
+
</html>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const ICON_OPENAUTH = (
|
|
105
|
+
<svg
|
|
106
|
+
data-component="logo-default"
|
|
107
|
+
width="51"
|
|
108
|
+
height="51"
|
|
109
|
+
viewBox="0 0 51 51"
|
|
110
|
+
fill="none"
|
|
111
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
112
|
+
>
|
|
113
|
+
<path
|
|
114
|
+
d="M0 50.2303V0.12854H50.1017V50.2303H0ZM3.08002 11.8326H11.7041V3.20856H3.08002V11.8326ZM14.8526 11.8326H23.4766V3.20856H14.8526V11.8326ZM26.5566 11.8326H35.1807V3.20856H26.5566V11.8326ZM38.3292 11.8326H47.0217V3.20856H38.3292V11.8326ZM3.08002 23.6052H11.7041V14.9811H3.08002V23.6052ZM14.8526 23.6052H23.4766V14.9811H14.8526V23.6052ZM26.5566 23.6052H35.1807V14.9811H26.5566V23.6052ZM38.3292 23.6052H47.0217V14.9811H38.3292V23.6052ZM3.08002 35.3092H11.7041V26.6852H3.08002V35.3092ZM14.8526 35.3092H23.4766V26.6852H14.8526V35.3092ZM26.5566 35.3092H35.1807V26.6852H26.5566V35.3092ZM38.3292 35.3092H47.0217V26.6852H38.3292V35.3092ZM3.08002 47.1502H11.7041V38.3893H3.08002V47.1502ZM14.8526 47.1502H23.4766V38.3893H14.8526V47.1502ZM26.5566 47.1502H35.1807V38.3893H26.5566V47.1502ZM38.3292 47.1502H47.0217V38.3893H38.3292V47.1502Z"
|
|
115
|
+
fill="currentColor"
|
|
116
|
+
/>
|
|
117
|
+
</svg>
|
|
118
|
+
)
|
package/src/ui/code.tsx
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configure the UI that's used by the Code provider.
|
|
3
|
+
*
|
|
4
|
+
* ```ts {1,7-12}
|
|
5
|
+
* import { CodeUI } from "@openauthjs/openauth/ui/code"
|
|
6
|
+
* import { CodeProvider } from "@openauthjs/openauth/provider/code"
|
|
7
|
+
*
|
|
8
|
+
* export default issuer({
|
|
9
|
+
* providers: {
|
|
10
|
+
* code: CodeAdapter(
|
|
11
|
+
* CodeUI({
|
|
12
|
+
* copy: {
|
|
13
|
+
* code_info: "We'll send a pin code to your email"
|
|
14
|
+
* },
|
|
15
|
+
* sendCode: (claims, code) => console.log(claims.email, code)
|
|
16
|
+
* })
|
|
17
|
+
* )
|
|
18
|
+
* },
|
|
19
|
+
* // ...
|
|
20
|
+
* })
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* @packageDocumentation
|
|
24
|
+
*/
|
|
25
|
+
/** @jsxImportSource hono/jsx */
|
|
26
|
+
|
|
27
|
+
import { CodeProviderOptions, CodeProviderError } from "../provider/code.js"
|
|
28
|
+
import { UnknownStateError } from "../error.js"
|
|
29
|
+
import { Layout } from "./base.js"
|
|
30
|
+
import { FormAlert } from "./form.js"
|
|
31
|
+
|
|
32
|
+
const DEFAULT_COPY = {
|
|
33
|
+
/**
|
|
34
|
+
* Copy for the email input.
|
|
35
|
+
*/
|
|
36
|
+
email_placeholder: "Email",
|
|
37
|
+
/**
|
|
38
|
+
* Error message when the email is invalid.
|
|
39
|
+
*/
|
|
40
|
+
email_invalid: "Email address is not valid",
|
|
41
|
+
/**
|
|
42
|
+
* Copy for the continue button.
|
|
43
|
+
*/
|
|
44
|
+
button_continue: "Continue",
|
|
45
|
+
/**
|
|
46
|
+
* Copy informing that the pin code will be emailed.
|
|
47
|
+
*/
|
|
48
|
+
code_info: "We'll send a pin code to your email.",
|
|
49
|
+
/**
|
|
50
|
+
* Copy for the pin code input.
|
|
51
|
+
*/
|
|
52
|
+
code_placeholder: "Code",
|
|
53
|
+
/**
|
|
54
|
+
* Error message when the code is invalid.
|
|
55
|
+
*/
|
|
56
|
+
code_invalid: "Invalid code",
|
|
57
|
+
/**
|
|
58
|
+
* Copy for when the code was sent.
|
|
59
|
+
*/
|
|
60
|
+
code_sent: "Code sent to ",
|
|
61
|
+
/**
|
|
62
|
+
* Copy for when the code was resent.
|
|
63
|
+
*/
|
|
64
|
+
code_resent: "Code resent to ",
|
|
65
|
+
/**
|
|
66
|
+
* Copy for the link to resend the code.
|
|
67
|
+
*/
|
|
68
|
+
code_didnt_get: "Didn't get code?",
|
|
69
|
+
/**
|
|
70
|
+
* Copy for the resend button.
|
|
71
|
+
*/
|
|
72
|
+
code_resend: "Resend",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type CodeUICopy = typeof DEFAULT_COPY
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Configure the password UI.
|
|
79
|
+
*/
|
|
80
|
+
export interface CodeUIOptions {
|
|
81
|
+
/**
|
|
82
|
+
* Callback to send the pin code to the user.
|
|
83
|
+
*
|
|
84
|
+
* The `claims` object contains the email or phone number of the user. You can send the code
|
|
85
|
+
* using this.
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
* ```ts
|
|
89
|
+
* async (claims, code) => {
|
|
90
|
+
* // Send the code via the claim
|
|
91
|
+
* }
|
|
92
|
+
* ```
|
|
93
|
+
*/
|
|
94
|
+
sendCode: (
|
|
95
|
+
claims: Record<string, string>,
|
|
96
|
+
code: string,
|
|
97
|
+
) => Promise<void | CodeProviderError>
|
|
98
|
+
/**
|
|
99
|
+
* Custom copy for the UI.
|
|
100
|
+
*/
|
|
101
|
+
copy?: Partial<CodeUICopy>
|
|
102
|
+
/**
|
|
103
|
+
* The mode to use for the input.
|
|
104
|
+
* @default "email"
|
|
105
|
+
*/
|
|
106
|
+
mode?: "email" | "phone"
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Creates a UI for the Code provider flow.
|
|
111
|
+
* @param props - Configure the UI.
|
|
112
|
+
*/
|
|
113
|
+
export function CodeUI(props: CodeUIOptions): CodeProviderOptions {
|
|
114
|
+
const copy = {
|
|
115
|
+
...DEFAULT_COPY,
|
|
116
|
+
...props.copy,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const mode = props.mode ?? "email"
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
sendCode: props.sendCode,
|
|
123
|
+
length: 6,
|
|
124
|
+
request: async (_req, state, _form, error): Promise<Response> => {
|
|
125
|
+
if (state.type === "start") {
|
|
126
|
+
const jsx = (
|
|
127
|
+
<Layout>
|
|
128
|
+
<form data-component="form" method="post">
|
|
129
|
+
{error?.type === "invalid_claim" && (
|
|
130
|
+
<FormAlert message={copy.email_invalid} />
|
|
131
|
+
)}
|
|
132
|
+
<input type="hidden" name="action" value="request" />
|
|
133
|
+
<input
|
|
134
|
+
data-component="input"
|
|
135
|
+
autofocus
|
|
136
|
+
type={mode === "email" ? "email" : "tel"}
|
|
137
|
+
name={mode === "email" ? "email" : "phone"}
|
|
138
|
+
inputmode={mode === "email" ? "email" : "numeric"}
|
|
139
|
+
required
|
|
140
|
+
placeholder={copy.email_placeholder}
|
|
141
|
+
/>
|
|
142
|
+
<button data-component="button">{copy.button_continue}</button>
|
|
143
|
+
</form>
|
|
144
|
+
<p data-component="form-footer">{copy.code_info}</p>
|
|
145
|
+
</Layout>
|
|
146
|
+
)
|
|
147
|
+
return new Response(jsx.toString(), {
|
|
148
|
+
headers: {
|
|
149
|
+
"Content-Type": "text/html",
|
|
150
|
+
},
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (state.type === "code") {
|
|
155
|
+
const jsx = (
|
|
156
|
+
<Layout>
|
|
157
|
+
<form data-component="form" class="form" method="post">
|
|
158
|
+
{error?.type === "invalid_code" && (
|
|
159
|
+
<FormAlert message={copy.code_invalid} />
|
|
160
|
+
)}
|
|
161
|
+
{state.type === "code" && (
|
|
162
|
+
<FormAlert
|
|
163
|
+
message={
|
|
164
|
+
(state.resend ? copy.code_resent : copy.code_sent) +
|
|
165
|
+
state.claims.email
|
|
166
|
+
}
|
|
167
|
+
color="success"
|
|
168
|
+
/>
|
|
169
|
+
)}
|
|
170
|
+
<input type="hidden" name="action" value="verify" />
|
|
171
|
+
<input
|
|
172
|
+
data-component="input"
|
|
173
|
+
autofocus
|
|
174
|
+
minLength={6}
|
|
175
|
+
maxLength={6}
|
|
176
|
+
type="text"
|
|
177
|
+
name="code"
|
|
178
|
+
required
|
|
179
|
+
inputmode="numeric"
|
|
180
|
+
autocomplete="one-time-code"
|
|
181
|
+
placeholder={copy.code_placeholder}
|
|
182
|
+
/>
|
|
183
|
+
<button data-component="button">{copy.button_continue}</button>
|
|
184
|
+
</form>
|
|
185
|
+
<form method="post">
|
|
186
|
+
{Object.entries(state.claims).map(([key, value]) => (
|
|
187
|
+
<input
|
|
188
|
+
key={key}
|
|
189
|
+
type="hidden"
|
|
190
|
+
name={key}
|
|
191
|
+
value={value}
|
|
192
|
+
className="hidden"
|
|
193
|
+
/>
|
|
194
|
+
))}
|
|
195
|
+
<input type="hidden" name="action" value="resend" />
|
|
196
|
+
<div data-component="form-footer">
|
|
197
|
+
<span>
|
|
198
|
+
{copy.code_didnt_get}{" "}
|
|
199
|
+
<button data-component="link">{copy.code_resend}</button>
|
|
200
|
+
</span>
|
|
201
|
+
</div>
|
|
202
|
+
</form>
|
|
203
|
+
</Layout>
|
|
204
|
+
)
|
|
205
|
+
return new Response(jsx.toString(), {
|
|
206
|
+
headers: {
|
|
207
|
+
"Content-Type": "text/html",
|
|
208
|
+
},
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
throw new UnknownStateError()
|
|
213
|
+
},
|
|
214
|
+
}
|
|
215
|
+
}
|
package/src/ui/form.tsx
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** @jsxImportSource hono/jsx */
|
|
2
|
+
|
|
3
|
+
export function FormAlert(props: {
|
|
4
|
+
message?: string
|
|
5
|
+
color?: "danger" | "success"
|
|
6
|
+
}) {
|
|
7
|
+
return (
|
|
8
|
+
<div data-component="form-alert" data-color={props.color}>
|
|
9
|
+
<svg
|
|
10
|
+
data-slot="icon-success"
|
|
11
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
12
|
+
fill="none"
|
|
13
|
+
viewBox="0 0 24 24"
|
|
14
|
+
stroke-width="1.5"
|
|
15
|
+
stroke="currentColor"
|
|
16
|
+
>
|
|
17
|
+
<path
|
|
18
|
+
stroke-linecap="round"
|
|
19
|
+
stroke-linejoin="round"
|
|
20
|
+
d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
|
|
21
|
+
/>
|
|
22
|
+
</svg>
|
|
23
|
+
<svg
|
|
24
|
+
data-slot="icon-danger"
|
|
25
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
26
|
+
fill="none"
|
|
27
|
+
viewBox="0 0 24 24"
|
|
28
|
+
stroke-width="1.5"
|
|
29
|
+
stroke="currentColor"
|
|
30
|
+
>
|
|
31
|
+
<path
|
|
32
|
+
stroke-linecap="round"
|
|
33
|
+
stroke-linejoin="round"
|
|
34
|
+
d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z"
|
|
35
|
+
/>
|
|
36
|
+
</svg>
|
|
37
|
+
<span data-slot="message">{props.message}</span>
|
|
38
|
+
</div>
|
|
39
|
+
)
|
|
40
|
+
}
|