@auth-link/web 0.0.3 → 0.0.5
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/CHANGELOG.md +11 -0
- package/README.md +581 -1783
- package/docs/README.en.md +200 -323
- package/docs/README.ja.md +200 -323
- package/docs/README.zh-CN.md +201 -394
- package/examples/react-client.tsx +42 -0
- package/examples/vue-client.vue +55 -0
- package/package.json +6 -3
- package/docs/go-vue.en.md +0 -254
- package/docs/go-vue.ja.md +0 -254
- package/docs/go-vue.zh-CN.md +0 -258
- package/examples/vue-cms.vue +0 -109
package/README.md
CHANGED
|
@@ -4,2049 +4,847 @@
|
|
|
4
4
|
|
|
5
5
|
## English
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Frontend dialogs for phone binding and in-app confirmation, with Vue 3, React 18/19 and plain JavaScript. Follow installation, session loading, client initialization, dialogs and event cleanup in this order.
|
|
8
8
|
|
|
9
|
-
###
|
|
10
|
-
|
|
11
|
-
Start with [Go + Vue: login, binding and confirmed publication](#go-vue-cms-en) and the `examples/vue-cms.vue`.
|
|
12
|
-
|
|
13
|
-
The flow is site login → Go `/api/session` returns authenticated/csrf → configure the cross-origin client with `csrfToken: () => session.csrf` → create bind/confirm → Go consumes, validates and commits the receipt → Vue dismisses the dialog → a separate business API checks the saved receipt and publishes. `baseUrl` targets Go; `authLinkOrigin` identifies AuthLink. Tenant keys stay in Go. Use your own fetch plus `validateTicket` for post/version fields; SDK create() sends purpose only.
|
|
14
|
-
|
|
15
|
-
### Features
|
|
16
|
-
|
|
17
|
-
- Phone-binding QR codes generated locally in the browser.
|
|
18
|
-
- Matching numbers that update every 30 seconds, synchronized with the AuthLink app.
|
|
19
|
-
- Confirmation, rejection, expiry and error callbacks.
|
|
20
|
-
- React components and hook, Vue components and composable, and framework-independent tools.
|
|
21
|
-
- 13 interface languages, **Simplified Chinese by default**, with per-component language selection.
|
|
22
|
-
- TypeScript declarations, separate CSS and optional React/Vue peer dependencies.
|
|
23
|
-
|
|
24
|
-
This is a browser integration library. It works with your application backend, the AuthLink service and the mobile app. The service verifies the original phone’s hardware-backed proof after the user approves in the app. Screen-lock checks and system passkeys are not required. Installing the library alone does not create an authentication service.
|
|
25
|
-
|
|
26
|
-
### Install
|
|
9
|
+
### 1. Install and distinguish the two API origins
|
|
27
10
|
|
|
28
11
|
```sh
|
|
29
|
-
npm install @auth-link/web
|
|
12
|
+
npm install @auth-link/web@latest
|
|
13
|
+
# Also install the framework your project uses: vue, or react / react-dom.
|
|
30
14
|
```
|
|
31
15
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
1. Configure your backend's AuthLink tenant credentials.
|
|
37
|
-
2. Implement the two application backend endpoints below (same-origin or explicitly allowed cross-origin), authenticated to the current website user.
|
|
38
|
-
3. Create a request on a user action and pass its ticket to a component.
|
|
39
|
-
4. Your backend verifies and consumes the result before returning a confirmed response.
|
|
40
|
-
|
|
41
|
-
The browser can call this library directly. Tenant credentials and result consumption belong on your backend; never embed a tenant API key in frontend code. The supplied client defaults to same-origin and also supports explicitly allowed cross-origin backends. You can supply a custom `AuthLinkClient` to integrate another transport while preserving server-side credential handling and verification.
|
|
42
|
-
|
|
43
|
-
### Create a client (default same-origin session mode)
|
|
44
|
-
|
|
45
|
-
`readCsrfTokenFromYourCms` is application code, not an SDK export. It reads a CSRF token issued by your own backend for the current logged-in session; it must not generate one in the browser or return an AuthLink API key. The name does not require a CMS.
|
|
46
|
-
|
|
47
|
-
Reuse your framework's existing CSRF mechanism. Otherwise, generate a cryptographically random token on the server (for example, Node's `randomBytes(32).toString('base64url')`), persist it in the session, and render it into the authenticated page with HTML attribute escaping. `{{ csrfToken }}` below is a server-template variable, not a literal value. Do not publicly cache pages containing session tokens.
|
|
16
|
+
| Origin | Frontend option | Purpose |
|
|
17
|
+
| --- | --- | --- |
|
|
18
|
+
| Your application API, e.g. `https://cms-api.example.com` | baseUrl, allowedBackendOrigins | Session, request creation and results |
|
|
19
|
+
| AuthLink production API `https://api.getauthlink.com` | authLinkOrigin | Validate the AuthLink origin in the ticket |
|
|
48
20
|
|
|
49
|
-
|
|
50
|
-
<meta name="csrf-token" content="{{ csrfToken }}">
|
|
51
|
-
```
|
|
21
|
+
baseUrl is your own application API, not the AuthLink API or marketing site. The SDK requires your existing website login/session and the request routes below; it does not provide login/session endpoints. Never put tenant keys in browser code.
|
|
52
22
|
|
|
53
|
-
The
|
|
23
|
+
The root import provides clients and types; /vue and /react provide components and useAuthLink; explicitly import /styles.css. ESM and TypeScript, Node 22.13+ tooling; React 18.2+/19 or Vue 3.3+. Install only your framework.
|
|
54
24
|
|
|
55
|
-
|
|
25
|
+
### 2. Load the current session and initialize a client
|
|
56
26
|
|
|
57
27
|
```ts
|
|
58
|
-
// authlink-client.ts
|
|
59
28
|
import { createAuthLinkClient } from '@auth-link/web';
|
|
60
29
|
import '@auth-link/web/styles.css';
|
|
61
30
|
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
export const client = createAuthLinkClient({
|
|
71
|
-
baseUrl: '/api/authlink',
|
|
72
|
-
csrfToken: readCsrfTokenFromYourCms,
|
|
73
|
-
// Replace this example with the configured AuthLink service origin.
|
|
74
|
-
authLinkOrigin: 'https://auth.example.com',
|
|
75
|
-
});
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
Replace `https://auth.example.com` with the configured AuthLink origin. Create requests only in user event handlers, as in the React example below.
|
|
79
|
-
|
|
80
|
-
### Separate frontend and backend origins (since 0.0.2)
|
|
81
|
-
|
|
82
|
-
The default is still same-origin `/api/authlink`. For direct cross-origin requests, configure an absolute `baseUrl` and exact `allowedBackendOrigins`, for example frontend `https://www.example.com` and application API `https://api.example.com`. Subdomains and different ports are different origins. Entries must be origins only, without paths, wildcards or embedded credentials, and must come from trusted configuration. HTTPS is required except for explicitly allowed HTTP loopback development origins (localhost, 127.0.0.1, [::1]).
|
|
83
|
-
|
|
84
|
-
The SDK validates the destination before evaluating credentials and refuses redirects. `authLinkOrigin` separately validates the AuthLink service origin encoded in the QR; it is not the application API allowlist.
|
|
85
|
-
|
|
86
|
-
#### Cross-origin session cookies
|
|
87
|
-
|
|
88
|
-
This complete initialization function uses an example `GET /api/session` endpoint that **your application implements**, returning `{ "csrfToken": "current-session-token" }` for the authenticated session with `Cache-Control: no-store` and CORS enabled. Replace that path with your existing session endpoint.
|
|
89
|
-
|
|
90
|
-
`csrfToken` is the JSON property chosen for this generic example. The Go CMS walkthrough uses `{ authenticated: true, csrf: '…' }` instead; map session.csrf to the SDK csrfToken callback without renaming your backend response.
|
|
91
|
-
|
|
92
|
-
```ts
|
|
93
|
-
import { createAuthLinkClient } from '@auth-link/web';
|
|
94
|
-
|
|
95
|
-
/** /api/session is an example endpoint implemented by your application backend. */
|
|
96
|
-
export async function createSessionBackendClient(backendOrigin: string) {
|
|
97
|
-
const response = await fetch(`${backendOrigin}/api/session`, {
|
|
98
|
-
credentials: 'include', cache: 'no-store', redirect: 'error',
|
|
31
|
+
// getCsrf reads your website's CURRENT session token on every request.
|
|
32
|
+
export function createWebsiteClient(getCsrf: () => string, backendOrigin?: string) {
|
|
33
|
+
return createAuthLinkClient({
|
|
34
|
+
baseUrl: backendOrigin ? `${backendOrigin}/api/authlink` : '/api/authlink',
|
|
35
|
+
allowedBackendOrigins: backendOrigin ? [backendOrigin] : [],
|
|
36
|
+
credentials: backendOrigin ? 'include' : 'same-origin',
|
|
37
|
+
csrfToken: getCsrf,
|
|
38
|
+
authLinkOrigin: 'https://api.getauthlink.com',
|
|
99
39
|
});
|
|
100
|
-
if (!response.ok) throw new Error('Session initialization failed');
|
|
101
|
-
const session: unknown = await response.json();
|
|
102
|
-
if (!session || typeof session !== 'object' || !('csrfToken' in session) ||
|
|
103
|
-
typeof session.csrfToken !== 'string' || !session.csrfToken) {
|
|
104
|
-
throw new Error('The session endpoint must return { csrfToken: string }');
|
|
105
|
-
}
|
|
106
|
-
let currentCsrfToken = session.csrfToken;
|
|
107
|
-
return {
|
|
108
|
-
client: createAuthLinkClient({
|
|
109
|
-
baseUrl: `${backendOrigin}/api/authlink`,
|
|
110
|
-
allowedBackendOrigins: [backendOrigin],
|
|
111
|
-
credentials: 'include',
|
|
112
|
-
csrfToken: () => currentCsrfToken,
|
|
113
|
-
}),
|
|
114
|
-
// Call after your application's session/token refresh; recreate on account changes.
|
|
115
|
-
updateCsrfToken(value: string) { currentCsrfToken = value; },
|
|
116
|
-
};
|
|
117
40
|
}
|
|
118
41
|
```
|
|
119
42
|
|
|
120
|
-
After login,
|
|
121
|
-
|
|
122
|
-
#### Cross-origin Bearer / JWT
|
|
123
|
-
|
|
124
|
-
Pass your website's existing mutable login state. Its login/refresh flow updates `auth.accessToken`; the callback reads it on every creation and poll.
|
|
43
|
+
After website login, read your existing session endpoint. This example uses the CMS response authenticated/csrf. If your response field is csrfToken, read that field instead and supply it to the SDK csrfToken callback.
|
|
125
44
|
|
|
126
45
|
```ts
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
});
|
|
46
|
+
// Run in your website's existing authenticated browser flow.
|
|
47
|
+
const backendOrigin = 'https://cms-api.example.com'; // YOUR application backend
|
|
48
|
+
const response = await fetch(`${backendOrigin}/api/session`, {
|
|
49
|
+
credentials: 'include', cache: 'no-store', redirect: 'error',
|
|
50
|
+
});
|
|
51
|
+
if (!response.ok) throw new Error('Session unavailable');
|
|
52
|
+
let session = await response.json();
|
|
53
|
+
if (session.authenticated !== true || typeof session.csrf !== 'string' || !session.csrf) {
|
|
54
|
+
throw new Error('Log in to your website first');
|
|
137
55
|
}
|
|
56
|
+
const client = createWebsiteClient(() => session.csrf, backendOrigin);
|
|
57
|
+
// Same origin: createWebsiteClient(() => session.csrf)
|
|
58
|
+
// After token refresh, replace session or update session.csrf.
|
|
59
|
+
// On logout/account/session change, close/unmount old dialogs and recreate client state.
|
|
138
60
|
```
|
|
139
61
|
|
|
140
|
-
|
|
62
|
+
GET /api/session is your application's example route; adapt it. Same-origin uses relative URLs and same-origin cookies. Cross-origin cookies need include and an application API that already allows your frontend origin; browser cookie policy still applies. Do not read HttpOnly cookies, generate your own CSRF token, or substitute a matching number.
|
|
141
63
|
|
|
142
|
-
|
|
64
|
+
For existing Bearer/JWT login, auth below is your website's mutable login state. Read its refreshed accessToken on every request. This is not an AuthLink tenant key. CSRF may be omitted only for Bearer with credentials=omit; sending cookies still requires csrfToken.
|
|
143
65
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
Vary: Origin
|
|
66
|
+
```ts
|
|
67
|
+
const backendOrigin = 'https://cms-api.example.com'; // YOUR application backend
|
|
68
|
+
const client = createAuthLinkClient({
|
|
69
|
+
baseUrl: `${backendOrigin}/api/authlink`, allowedBackendOrigins: [backendOrigin],
|
|
70
|
+
credentials: 'omit', accessToken: () => auth.accessToken,
|
|
71
|
+
authLinkOrigin: 'https://api.getauthlink.com',
|
|
72
|
+
});
|
|
152
73
|
```
|
|
153
74
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
#### Transport options
|
|
157
|
-
|
|
158
|
-
| Option | Behavior |
|
|
159
|
-
| --- | --- |
|
|
160
|
-
| `baseUrl` | Application API prefix, defaults to `/api/authlink`; absolute for cross-origin |
|
|
161
|
-
| `allowedBackendOrigins` | Additional exact application backend origins; same-origin remains allowed |
|
|
162
|
-
| `credentials` | Defaults to `same-origin`, or `omit` with `accessToken`; use `include` for cross-origin cookies |
|
|
163
|
-
| `csrfToken` | Per-request sync/async token callback, required for cookie/session mode |
|
|
164
|
-
| `csrfHeaderName` | Defaults to `X-CSRF-Token`, configurable for framework integration |
|
|
165
|
-
| `accessToken` | Per-request sync/async website login token callback, without the `Bearer ` prefix |
|
|
166
|
-
| `headers` | Extra `HeadersInit`, or sync/async callback returning it |
|
|
167
|
-
| `authLinkOrigin` | Trusted QR service origin, independent of the application API origin |
|
|
168
|
-
| `fetch` | Optional implementation; destination validation still applies |
|
|
169
|
-
|
|
170
|
-
Custom headers cannot override SDK-managed `Accept`, `Content-Type`, `Authorization`, `Idempotency-Key` or the configured CSRF header (`RESERVED_HEADER`). Use `accessToken` for Bearer auth and `csrfHeaderName` to adapt framework CSRF validation. Import `ClientOptions` from the root entry for configuration types.
|
|
171
|
-
|
|
172
|
-
Unlisted origins produce `BACKEND_ORIGIN_NOT_ALLOWED` (replacing `CMS_ORIGIN_REQUIRED`); invalid allowlist entries or URLs produce `INVALID_BACKEND_ORIGIN` / `INVALID_BASE_URL`. Missing login/CSRF tokens produce `ACCESS_TOKEN_REQUIRED` / `CSRF_REQUIRED`. Cancellation and the 10-second timeout also cover async credential providers. Browser CORS failures normally surface as `NETWORK_ERROR`; inspect browser developer tools for details.
|
|
75
|
+
### 3. Vue: create on click, display and clean up
|
|
173
76
|
|
|
174
|
-
|
|
77
|
+
The package includes `examples/vue-client.vue`; copy it as AuthLinkPanel.vue. After login pass the current session.csrf, and backendOrigin for cross-origin calls. Unmount on logout/account/session changes and mount a new instance. Update the csrf prop after token refresh.
|
|
175
78
|
|
|
176
|
-
```
|
|
177
|
-
|
|
178
|
-
import {
|
|
79
|
+
```vue
|
|
80
|
+
<script setup lang="ts">
|
|
81
|
+
import { markRaw, onUnmounted, ref, shallowRef } from 'vue';
|
|
82
|
+
import { createAuthLinkClient, requestKey } from '@auth-link/web';
|
|
83
|
+
import type { Confirmation, Mode, Ticket } from '@auth-link/web';
|
|
84
|
+
import { AuthLinkBind, AuthLinkConfirm } from '@auth-link/web/vue';
|
|
179
85
|
import '@auth-link/web/styles.css';
|
|
180
86
|
|
|
181
|
-
//
|
|
182
|
-
const
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
87
|
+
// csrf comes from your website's current authenticated session.
|
|
88
|
+
const props = defineProps<{ csrf: string; backendOrigin?: string }>();
|
|
89
|
+
const ticket = shallowRef<Ticket | null>(null);
|
|
90
|
+
const busy = ref(false), message = ref('');
|
|
91
|
+
const lifetime = new AbortController();
|
|
92
|
+
let creationKey: string | null = null;
|
|
93
|
+
let creationMode: Mode | null = null;
|
|
94
|
+
const client = markRaw(createAuthLinkClient({
|
|
95
|
+
baseUrl: props.backendOrigin ? `${props.backendOrigin}/api/authlink` : '/api/authlink',
|
|
96
|
+
allowedBackendOrigins: props.backendOrigin ? [props.backendOrigin] : [],
|
|
97
|
+
credentials: props.backendOrigin ? 'include' : 'same-origin',
|
|
98
|
+
csrfToken: () => props.csrf,
|
|
99
|
+
authLinkOrigin: 'https://api.getauthlink.com',
|
|
100
|
+
}));
|
|
101
|
+
function finish(text: string) { ticket.value = null; message.value = text; }
|
|
102
|
+
function confirmed(result: Confirmation) {
|
|
103
|
+
finish(`${result.purpose}: ${result.requestId}`);
|
|
104
|
+
// Reload your website's account/status API if the page needs fresh state.
|
|
105
|
+
}
|
|
106
|
+
function showError(error: Error) { message.value = error.message; }
|
|
107
|
+
async function start(mode: Mode) {
|
|
108
|
+
if (busy.value || ticket.value) return;
|
|
109
|
+
busy.value = true; message.value = '';
|
|
110
|
+
try {
|
|
111
|
+
if (creationMode !== mode) { creationKey = null; creationMode = mode; }
|
|
112
|
+
creationKey ??= requestKey();
|
|
113
|
+
const value = await client.create(mode, { idempotencyKey: creationKey, signal: lifetime.signal });
|
|
114
|
+
if (lifetime.signal.aborted) return;
|
|
115
|
+
ticket.value = value; creationKey = null;
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (!lifetime.signal.aborted) showError(error as Error);
|
|
118
|
+
// Keep the same key when retrying an uncertain creation of the same mode.
|
|
119
|
+
} finally { busy.value = false; }
|
|
120
|
+
}
|
|
121
|
+
onUnmounted(() => { lifetime.abort(); ticket.value = null; creationKey = null; });
|
|
122
|
+
</script>
|
|
123
|
+
|
|
124
|
+
<template>
|
|
125
|
+
<button :disabled="busy || !!ticket" @click="start('bind')">Bind phone</button>
|
|
126
|
+
<button :disabled="busy || !!ticket" @click="start('confirm')">Confirm</button>
|
|
127
|
+
<AuthLinkBind v-if="ticket?.purpose === 'bind'" :ticket="ticket" :client="client" variant="B"
|
|
128
|
+
@confirmed="confirmed" @close="finish('Closed')" @rejected="finish('Rejected')"
|
|
129
|
+
@expired="finish('Expired; start again')" @error="showError" />
|
|
130
|
+
<AuthLinkConfirm v-else-if="ticket?.purpose === 'confirm'" :ticket="ticket" :client="client" variant="B"
|
|
131
|
+
@confirmed="confirmed" @close="finish('Closed')" @rejected="finish('Rejected')"
|
|
132
|
+
@expired="finish('Expired; start again')" @error="showError" />
|
|
133
|
+
<p role="status">{{ message }}</p>
|
|
134
|
+
</template>
|
|
191
135
|
```
|
|
192
136
|
|
|
193
|
-
|
|
137
|
+
Keep ticket in shallowRef and client in markRaw. Retain a failed creation's key when retrying the same mode; clear it only after success. Create only in user handlers, never mounted/watch. Confirmation, rejection, expiry and close clear the ticket and unmount the dialog. Keep the dialog on network errors and use its retry control for the current request. Reload actual account state from your own API when needed.
|
|
194
138
|
|
|
195
|
-
### React
|
|
139
|
+
### 4. React
|
|
196
140
|
|
|
197
|
-
|
|
141
|
+
Pass a stable client created as above and mode=bind or confirm. Unmount the instance on logout/account/session changes.
|
|
198
142
|
|
|
199
143
|
```tsx
|
|
200
144
|
"use client";
|
|
201
|
-
import { useRef, useState } from 'react';
|
|
145
|
+
import { useEffect, useRef, useState } from 'react';
|
|
202
146
|
import { requestKey } from '@auth-link/web';
|
|
203
|
-
import type { Mode, Ticket } from '@auth-link/web';
|
|
147
|
+
import type { AuthLinkClient, Mode, Ticket } from '@auth-link/web';
|
|
204
148
|
import { AuthLinkBind, AuthLinkConfirm } from '@auth-link/web/react';
|
|
205
|
-
|
|
149
|
+
// Import '@auth-link/web/styles.css' once in your application entry.
|
|
206
150
|
|
|
207
|
-
export function AuthLinkPanel({ mode }: { mode: Mode }) {
|
|
151
|
+
export function AuthLinkPanel({ client, mode }: { client: AuthLinkClient; mode: Mode }) {
|
|
208
152
|
const [ticket, setTicket] = useState<Ticket | null>(null);
|
|
209
153
|
const [busy, setBusy] = useState(false);
|
|
210
154
|
const [message, setMessage] = useState('');
|
|
211
|
-
const
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
}
|
|
221
|
-
function showConfirmed(requestId: string) {
|
|
222
|
-
finish(`Confirmed (request ${requestId})`);
|
|
223
|
-
}
|
|
224
|
-
const start = async () => {
|
|
225
|
-
if (creating.current || ticket) return;
|
|
226
|
-
creating.current = true;
|
|
227
|
-
setBusy(true);
|
|
228
|
-
setMessage('');
|
|
155
|
+
const creation = useRef<{ mode: Mode; key: string } | null>(null);
|
|
156
|
+
const inFlight = useRef(false);
|
|
157
|
+
const controller = useRef<AbortController | null>(null);
|
|
158
|
+
useEffect(() => () => { controller.current?.abort(); }, []);
|
|
159
|
+
function finish(text: string) { setTicket(null); setMessage(text); }
|
|
160
|
+
async function start() {
|
|
161
|
+
if (inFlight.current || ticket) return;
|
|
162
|
+
inFlight.current = true; setBusy(true); setMessage('');
|
|
163
|
+
const attempt = new AbortController(); controller.current = attempt;
|
|
229
164
|
try {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
165
|
+
if (creation.current?.mode !== mode) creation.current = { mode, key: requestKey() };
|
|
166
|
+
const value = await client.create(mode, { idempotencyKey: creation.current.key, signal: attempt.signal });
|
|
167
|
+
if (attempt.signal.aborted) return;
|
|
168
|
+
setTicket(value); creation.current = null;
|
|
169
|
+
} catch (error) { if (!attempt.signal.aborted) setMessage((error as Error).message); }
|
|
170
|
+
finally {
|
|
171
|
+
inFlight.current = false;
|
|
172
|
+
if (!attempt.signal.aborted) setBusy(false);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
236
175
|
const Component = mode === 'bind' ? AuthLinkBind : AuthLinkConfirm;
|
|
237
176
|
return <>
|
|
238
|
-
{!ticket && <button
|
|
239
|
-
{ticket && <Component ticket={ticket} client={client}
|
|
240
|
-
onConfirmed={result => result.purpose
|
|
241
|
-
onClose={() => finish('Closed')}
|
|
242
|
-
|
|
243
|
-
onExpired={() => finish('Request expired; start again')}
|
|
177
|
+
{!ticket && <button disabled={busy} onClick={() => void start()}>Start verification</button>}
|
|
178
|
+
{ticket && <Component ticket={ticket} client={client} variant="B"
|
|
179
|
+
onConfirmed={result => finish(`${result.purpose}: ${result.requestId}`)}
|
|
180
|
+
onClose={() => finish('Closed')} onRejected={() => finish('Rejected')}
|
|
181
|
+
onExpired={() => finish('Expired; start again')}
|
|
244
182
|
onError={error => setMessage(error.message)} />}
|
|
245
183
|
<p role="status">{message}</p>
|
|
246
184
|
</>;
|
|
247
185
|
}
|
|
248
186
|
```
|
|
249
187
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
Mounting either component opens a dialog; unmount it in `onClose` (for example, clear the selected ticket). SSR renders no dialog and makes no requests. Omit `locale` for Simplified Chinese. Use `locale="zh-CN"` for Simplified Chinese or another code from the language table. Keep `client` and `ticket` stable between renders. The React entry preserves `"use client"`; SSR rendering does not create requests.
|
|
254
|
-
|
|
255
|
-
### Vue
|
|
256
|
-
|
|
257
|
-
```vue
|
|
258
|
-
<script setup lang="ts">
|
|
259
|
-
import { AuthLinkBind, AuthLinkConfirm } from '@auth-link/web/vue';
|
|
260
|
-
import type { Ticket, Confirmation } from '@auth-link/web';
|
|
261
|
-
import { client } from './authlink-client';
|
|
262
|
-
|
|
263
|
-
defineProps<{ ticket: Ticket }>();
|
|
264
|
-
function confirmed(result: Confirmation) {
|
|
265
|
-
console.log('Confirmed request:', result.requestId);
|
|
266
|
-
}
|
|
267
|
-
</script>
|
|
268
|
-
|
|
269
|
-
<template>
|
|
270
|
-
<AuthLinkBind v-if="ticket.purpose === 'bind'" :ticket="ticket" :client="client"
|
|
271
|
-
@confirmed="confirmed" />
|
|
272
|
-
<AuthLinkConfirm v-else :ticket="ticket" :client="client" locale="ja"
|
|
273
|
-
@confirmed="confirmed" />
|
|
274
|
-
</template>
|
|
275
|
-
```
|
|
276
|
-
|
|
277
|
-
Use `:locale="selectedLanguage"` to switch languages without creating a new verification request. Mounting opens the dialog. Use `@close` to clear the selected ticket. Vue exposes `confirmed`, `rejected`, `expired`, `error` and `close` events.
|
|
278
|
-
|
|
279
|
-
### Choose a layout
|
|
280
|
-
|
|
281
|
-
Set `variant` when opening a dialog or mounting a component. Omit it for **A**.
|
|
282
|
-
|
|
283
|
-
| Value | Desktop | Mobile |
|
|
284
|
-
| --- | --- | --- |
|
|
285
|
-
| `A` (default) | Minimal centered card with a thin code-refresh progress bar | Centered card |
|
|
286
|
-
| `B` | QR and matching code in two columns | Vertical layout |
|
|
287
|
-
| `C` | Compact card with footer cancellation | Bottom sheet with safe-area padding |
|
|
188
|
+
### 5. Plain JavaScript / direct dialogs
|
|
288
189
|
|
|
289
190
|
```ts
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
// modal.setVariant('C'); // Presentation only; preserves the request and polling.
|
|
293
|
-
```
|
|
294
|
-
|
|
295
|
-
```tsx
|
|
296
|
-
<AuthLinkBind ticket={ticket} client={client} variant="C" />
|
|
297
|
-
```
|
|
191
|
+
import { openAuthLinkConfirm, requestKey } from '@auth-link/web';
|
|
192
|
+
import '@auth-link/web/styles.css';
|
|
298
193
|
|
|
299
|
-
|
|
300
|
-
|
|
194
|
+
// Inside a click handler; client is the stable instance created above.
|
|
195
|
+
const ticket = await client.create('confirm', { idempotencyKey: requestKey() });
|
|
196
|
+
const modal = openAuthLinkConfirm({
|
|
197
|
+
ticket, client, variant: 'B', locale: 'zh-CN',
|
|
198
|
+
onConfirmed: result => console.log(result.requestId),
|
|
199
|
+
onRejected: result => console.log(result.code),
|
|
200
|
+
onExpired: () => console.log('Expired'),
|
|
201
|
+
onError: error => console.log(error.message),
|
|
202
|
+
onClose: () => console.log('Closed'),
|
|
203
|
+
});
|
|
204
|
+
// Route/component cleanup: modal.destroy();
|
|
205
|
+
// Optional: modal.close(); modal.retry(); modal.setLocale('ja'); modal.setVariant('C');
|
|
301
206
|
```
|
|
302
207
|
|
|
303
|
-
|
|
208
|
+
For binding use client.create('bind', ...) and openAuthLinkBind(...). Confirmation/rejection dismiss before their callbacks and do not emit onClose. Close stops frontend observation; call destroy when leaving the page/component. Success is delivered once per request per instance. Callbacks update UI; protected actions still call your application's API.
|
|
304
209
|
|
|
305
|
-
|
|
210
|
+
### 6. API shapes the frontend needs
|
|
306
211
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
| Option | Type | Meaning |
|
|
310
|
-
| --- | --- | --- |
|
|
311
|
-
| `ticket` | `Ticket`, required | Existing request returned by your backend |
|
|
312
|
-
| `client` | `AuthLinkClient`, required | Client that queries your backend |
|
|
313
|
-
| `locale` | `Locale` | Interface language; defaults to `zh-CN` |
|
|
314
|
-
| `variant` | `DialogVariant` | `A` (default), `B` or `C`; changes layout only |
|
|
315
|
-
| `showQr` | `boolean` | Optional QR on confirmation panels; binding panels always show it |
|
|
316
|
-
| `intervalMs` | `number` | Polling interval, 250–10000 ms; default 1500 ms |
|
|
317
|
-
| `onRefresh` | `() => void` | Create a new request from the expiry panel and replace the ticket |
|
|
318
|
-
|
|
319
|
-
| React callback / Vue event | Argument | Meaning |
|
|
212
|
+
| Application route | Frontend sends | Frontend reads |
|
|
320
213
|
| --- | --- | --- |
|
|
321
|
-
|
|
|
322
|
-
|
|
|
323
|
-
| `onExpired` / `expired` | None | Request expired |
|
|
324
|
-
| `onClose` / `close` | None | Dialog closed; unmount the component / clear its ticket |
|
|
325
|
-
| `onError` / `error` | `Error` | Network, response validation or callback failure |
|
|
214
|
+
| POST /api/authlink/requests | purpose=bind or confirm; Idempotency-Key | Ticket |
|
|
215
|
+
| POST /api/authlink/requests/{requestId}/result | empty object; current login credentials | Result; polled automatically |
|
|
326
216
|
|
|
327
|
-
|
|
217
|
+
Both send the current CSRF header in cookie mode. Ticket contains requestId, purpose, qr, pairingCode and expiresAt; confirm also requires displayCode. Import Ticket/Result/Confirmation rather than inventing QR data or matching numbers. Only status=confirmed triggers success. new/pending/approved/consumed are waiting states; rejected/expired trigger their events.
|
|
328
218
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
### Backend contract
|
|
332
|
-
|
|
333
|
-
The default prefix is `/api/authlink`. Both endpoints authenticate the website user using session cookies plus the configured CSRF header, or Bearer authentication. Creation also sends a stable `Idempotency-Key`. Derive the account from the validated session/token rather than a browser-supplied account ID.
|
|
219
|
+
SDK create() sends purpose only. If your application expects additional fields, create via your own fetch, validate the returned ticket and pass it to the same component. post, session and backendOrigin below are your frontend state; retain the creation key across retries of the same payload.
|
|
334
220
|
|
|
335
221
|
```ts
|
|
336
|
-
import
|
|
337
|
-
|
|
338
|
-
//
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
"status": "confirmed",
|
|
350
|
-
"requestId": "<request-id>",
|
|
351
|
-
"verificationId": "<verification-id>",
|
|
352
|
-
"purpose": "confirm",
|
|
353
|
-
"authenticationPolicy": "device-presence.v1",
|
|
354
|
-
"expiresAt": "<same-expiry-as-ticket>"
|
|
355
|
-
}
|
|
222
|
+
import { requestKey, validateTicket } from '@auth-link/web';
|
|
223
|
+
|
|
224
|
+
// Keep this key across retries of the SAME operation and payload.
|
|
225
|
+
const idempotencyKey = requestKey();
|
|
226
|
+
const response = await fetch(`${backendOrigin}/api/authlink/requests`, {
|
|
227
|
+
method: 'POST', credentials: 'include', cache: 'no-store', redirect: 'error',
|
|
228
|
+
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': session.csrf,
|
|
229
|
+
'Idempotency-Key': idempotencyKey },
|
|
230
|
+
body: JSON.stringify({ purpose: 'confirm', postId: post.id, version: post.version }),
|
|
231
|
+
});
|
|
232
|
+
if (!response.ok) throw new Error('Creation failed');
|
|
233
|
+
const ticket = validateTicket(await response.json(), 'confirm', 'https://api.getauthlink.com');
|
|
234
|
+
// Pass ticket and the same client to AuthLinkConfirm.
|
|
356
235
|
```
|
|
357
236
|
|
|
358
|
-
|
|
237
|
+
### 7. Props, events and presentation
|
|
359
238
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
|
365
|
-
|
|
|
366
|
-
| `
|
|
367
|
-
| `
|
|
368
|
-
| `
|
|
369
|
-
| `
|
|
370
|
-
|
|
371
|
-
|
|
|
372
|
-
|
|
|
373
|
-
| `
|
|
374
|
-
| `
|
|
375
|
-
| `
|
|
376
|
-
| `
|
|
377
|
-
| `
|
|
378
|
-
| `vi` | Tiếng Việt |
|
|
239
|
+
| Prop | Type | Meaning |
|
|
240
|
+
| --- | --- | --- |
|
|
241
|
+
| `ticket` | `Ticket` | Required; the current request |
|
|
242
|
+
| `client` | `AuthLinkClient` | Required; keep the instance stable |
|
|
243
|
+
| `locale` | `Locale` | Default `zh-CN` |
|
|
244
|
+
| `variant` | `'A' / 'B' / 'C'` | Default `A` |
|
|
245
|
+
| `showQr` | `boolean` | Optional QR for confirm; bind always shows QR |
|
|
246
|
+
| `intervalMs` | `number` | Default 1500; range 250–10000 ms |
|
|
247
|
+
| `onRefresh` | `() => void` | Optional user action after expiry; create a new ticket |
|
|
248
|
+
| `className` | `string` | Custom dialog class; Vue also accepts `class` |
|
|
249
|
+
|
|
250
|
+
| React callback / Vue event | Value | Behavior |
|
|
251
|
+
| --- | --- | --- |
|
|
252
|
+
| `onConfirmed` / `confirmed` | `Confirmation` | Success; dialog closes automatically |
|
|
253
|
+
| `onRejected` / `rejected` | `Rejection` | `{status:'rejected',code:'REJECTED',requestId,purpose}`; dialog closes |
|
|
254
|
+
| `onExpired` / `expired` | — | Request expired |
|
|
255
|
+
| `onClose` / `close` | — | User closes the dialog; clear ticket |
|
|
256
|
+
| `onError` / `error` | `Error` | Read `error.message`; retain the dialog for retry |
|
|
379
257
|
|
|
380
|
-
|
|
258
|
+
A is a centered card (default), B splits on desktop and stacks on narrow screens, and C is compact with a mobile bottom sheet. Changing locale/variant does not create a request. Browser support for native dialog.showModal() is required.
|
|
381
259
|
|
|
382
|
-
|
|
260
|
+
Import CSS explicitly. Dialogs mount under document.body; set --authlink-foreground, --authlink-background, --authlink-accent and --authlink-muted on :root or .authlink-dialog. Vue class accepts strings/arrays/objects alongside className. SSR renders no dialog and creates no requests.
|
|
383
261
|
|
|
384
|
-
|
|
262
|
+
Locales: `zh-CN` (default), `zh-TW`, `en`, `ja`, `bn`, `es`, `fil`, `hi`, `id`, `ms`, `pt`, `th`, `vi`. Missing/unsupported values fall back to Simplified Chinese. Import supportedLocales for the list.
|
|
385
263
|
|
|
386
|
-
|
|
264
|
+
### 8. Transport options and troubleshooting
|
|
387
265
|
|
|
388
|
-
|
|
|
266
|
+
| Option | Purpose |
|
|
389
267
|
| --- | --- |
|
|
390
|
-
|
|
|
391
|
-
|
|
|
392
|
-
|
|
|
393
|
-
|
|
|
268
|
+
| `baseUrl` | Your application API prefix; default `/api/authlink` |
|
|
269
|
+
| `allowedBackendOrigins` | Exact extra application API origins; no paths/wildcards |
|
|
270
|
+
| `credentials` | Default `same-origin`; cross-origin cookies need `include` |
|
|
271
|
+
| `csrfToken` | Sync/async getter for the current website CSRF token |
|
|
272
|
+
| `csrfHeaderName` | Default `X-CSRF-Token` |
|
|
273
|
+
| `accessToken` | Sync/async website Bearer token getter, without `Bearer ` |
|
|
274
|
+
| `headers` | Extra headers or sync/async getter |
|
|
275
|
+
| `authLinkOrigin` | `https://api.getauthlink.com` for production AuthLink |
|
|
276
|
+
| `fetch` | Optional fetch implementation |
|
|
394
277
|
|
|
395
|
-
|
|
278
|
+
Custom headers cannot replace Accept, Content-Type, Authorization, Idempotency-Key or the configured CSRF header. Read error.message: CSRF_REQUIRED means check the session token; ACCESS_TOKEN_REQUIRED means check login state; BACKEND_ORIGIN_NOT_ALLOWED means check exact allowed origins; NETWORK_ERROR means inspect browser Network, connectivity and CORS. Requests time out after 10 seconds; closing/unmounting cancels observation.
|
|
396
279
|
|
|
397
|
-
|
|
398
|
-
- The browser receives only public tickets and current display frames, never generation seeds, device keys or tenant secrets.
|
|
399
|
-
- A request lasts at most 120 seconds; rotating the code does not extend it. Backend and app must support AuthLink mobile protocol 7.
|
|
400
|
-
- Countdown uses server time and a monotonic clock. Expired numbers are hidden even if a network call stalls; a short update gap at rotation is expected.
|
|
401
|
-
- Component unmount/request changes abort polling. Confirmation is deduplicated per mounted instance; your backend still needs persistent idempotence.
|
|
402
|
-
- Frontend callbacks update UI. Protected APIs must rely on trusted backend records, never a JavaScript callback or a browser-supplied success flag.
|
|
403
|
-
- QR generation is local; backend SVG is not injected and no third-party QR service receives the ticket. Allow `img-src 'self' data:` in your CSP.
|
|
280
|
+
Expired digits are hidden; do not generate or cache new ones in the frontend. QR generation is local; allow data: images if your page uses CSP. A callback, localStorage or a browser-supplied status is not authorization.
|
|
404
281
|
|
|
405
282
|
### License
|
|
406
283
|
|
|
407
|
-
MIT. See
|
|
408
|
-
|
|
409
|
-
### Go Vue CMS: en
|
|
284
|
+
MIT. See LICENSE and THIRD_PARTY_NOTICES.md.
|
|
410
285
|
|
|
411
|
-
|
|
286
|
+
---
|
|
412
287
|
|
|
413
|
-
|
|
288
|
+
## 简体中文
|
|
414
289
|
|
|
415
|
-
|
|
416
|
-
| --- | --- | --- |
|
|
417
|
-
| Vue | `https://cms.example.com` | `apiOrigin` is the Go backend below |
|
|
418
|
-
| Go CMS API | `https://cms-api.example.com` | `baseUrl=https://cms-api.example.com/api/authlink` |
|
|
419
|
-
| AuthLink API | `https://auth.example.com` | Go SDK `Origin`; browser `authLinkOrigin` |
|
|
290
|
+
用于手机绑定与 App 确认的前端组件,支持 Vue 3、React 18/19 和原生 JavaScript。本文按前端接入顺序说明安装、登录状态读取、客户端初始化、弹窗和事件处理。
|
|
420
291
|
|
|
421
|
-
|
|
292
|
+
### 1. 安装并区分两个 API 地址
|
|
422
293
|
|
|
423
294
|
```sh
|
|
424
|
-
npm install @auth-link/web@latest
|
|
425
|
-
#
|
|
426
|
-
go mod edit -replace=authlink.local/sdk=./third_party/authlink-go
|
|
427
|
-
go mod edit -require=authlink.local/sdk@v0.0.0
|
|
428
|
-
go mod tidy
|
|
429
|
-
```
|
|
430
|
-
|
|
431
|
-
The Go SDK is supplied as source or a Go SDK archive from your AuthLink website, not a public Go module or an npm backend. Preserve its own go.mod and import `authlink "authlink.local/sdk"`. Initialize it with trusted `authlink.Config{Origin: authOrigin, TenantID: tenantID, APIKey: apiKey}` through `authlink.New`.
|
|
432
|
-
|
|
433
|
-
#### 2. Log in to the website and load its session
|
|
434
|
-
|
|
435
|
-
Your login route (the reference CMS uses `POST /api/login`) issues a Secure, HttpOnly session cookie. Fetch Go's `GET /api/session` with `credentials:'include'`; require `authenticated:true` and the current `csrf` value before opening requests. Otherwise return to your own login flow.
|
|
436
|
-
|
|
437
|
-
Here the JSON property is **csrf**, while the SDK option is **csrfToken**: use `csrfToken: () => session.value?.csrf ?? ''`. A backend is free to name the response property differently. Go generates and saves the token for that session and validates X-CSRF-Token on creation AND result polling. Never substitute a tenant key or matching number.
|
|
438
|
-
|
|
439
|
-
Two HTTPS subdomains under the same site are cross-origin but same-site. The reference API sets a host-only cookie with `SameSite=Strict; Secure; HttpOnly; Path=/`, without Domain. Requests still target the API host. For a truly cross-site setup, account for browser cookie policy as well as CORS.
|
|
440
|
-
|
|
441
|
-
#### 3. Configure Go CORS and application routes
|
|
442
|
-
|
|
443
|
-
```http
|
|
444
|
-
Access-Control-Allow-Origin: https://cms.example.com
|
|
445
|
-
Access-Control-Allow-Credentials: true
|
|
446
|
-
Access-Control-Allow-Methods: GET, POST, PUT, OPTIONS
|
|
447
|
-
Access-Control-Allow-Headers: Content-Type, X-CSRF-Token, Idempotency-Key
|
|
448
|
-
Vary: Origin
|
|
449
|
-
Cache-Control: no-store
|
|
295
|
+
npm install @auth-link/web@latest
|
|
296
|
+
# 另行安装你的项目使用的 vue,或 react / react-dom。
|
|
450
297
|
```
|
|
451
298
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
| Application route | Input | Output |
|
|
299
|
+
| 地址 | 前端配置 | 用途 |
|
|
455
300
|
| --- | --- | --- |
|
|
456
|
-
| `
|
|
457
|
-
|
|
|
458
|
-
| `POST /api/authlink/requests/{id}/result` | `{}`; current session + CSRF | Pending status/displayCode or verified `confirmed` receipt |
|
|
459
|
-
| `POST /api/posts/{id}/publish` | `{requestId,version}`; current session + CSRF | `{ok:true}` after checking the saved confirmation and intent |
|
|
460
|
-
|
|
461
|
-
Your Go application implements all four routes. postId/version and the publication route are application-specific conventions, not AuthLink fields.
|
|
462
|
-
|
|
463
|
-
#### 4. Persist before creation; commit verification before confirmed
|
|
301
|
+
| 你的网站业务后台,如 `https://cms-api.example.com` | `baseUrl`、`allowedBackendOrigins` | 登录状态、创建请求和查询结果 |
|
|
302
|
+
| AuthLink 生产 API:`https://api.getauthlink.com` | `authLinkOrigin` | 核对 ticket 中的 AuthLink 服务来源 |
|
|
464
303
|
|
|
465
|
-
|
|
466
|
-
Vue → Go /api/session → current session + csrf
|
|
467
|
-
Vue → Go /api/authlink/requests → Go SDK CreateRequest → public ticket
|
|
468
|
-
Vue dialog → Go /api/authlink/requests/{id}/result → Status + Consume
|
|
469
|
-
Go commits verified receipt → Vue confirmed event
|
|
470
|
-
Vue → Go /api/posts/{id}/publish → validate saved intent/receipt → publish
|
|
471
|
-
```
|
|
304
|
+
`baseUrl` 不是 AuthLink API,也不是 AuthLink 官网。接入方的网站和业务 API 仍使用自己的域名。需要已有的网站登录状态和下文的请求接口;Web SDK 不提供登录或会话接口。租户密钥不能放到浏览器。
|
|
472
305
|
|
|
473
|
-
|
|
306
|
+
导入入口:根入口提供客户端和类型;`/vue`、`/react` 提供组件及 useAuthLink;`/styles.css` 必须显式导入。ESM、TypeScript,工具环境 Node 22.13+;React 18.2+/19、Vue 3.3+,只安装所用框架。
|
|
474
307
|
|
|
475
|
-
|
|
308
|
+
### 2. 读取当前登录状态并创建客户端
|
|
476
309
|
|
|
477
|
-
|
|
310
|
+
```ts
|
|
311
|
+
import { createAuthLinkClient } from '@auth-link/web';
|
|
312
|
+
import '@auth-link/web/styles.css';
|
|
478
313
|
|
|
479
|
-
|
|
480
|
-
|
|
314
|
+
// getCsrf reads your website's CURRENT session token on every request.
|
|
315
|
+
export function createWebsiteClient(getCsrf: () => string, backendOrigin?: string) {
|
|
316
|
+
return createAuthLinkClient({
|
|
317
|
+
baseUrl: backendOrigin ? `${backendOrigin}/api/authlink` : '/api/authlink',
|
|
318
|
+
allowedBackendOrigins: backendOrigin ? [backendOrigin] : [],
|
|
319
|
+
credentials: backendOrigin ? 'include' : 'same-origin',
|
|
320
|
+
csrfToken: getCsrf,
|
|
321
|
+
authLinkOrigin: 'https://api.getauthlink.com',
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
```
|
|
481
325
|
|
|
482
|
-
|
|
483
|
-
"context"
|
|
484
|
-
authlink "authlink.local/sdk"
|
|
485
|
-
)
|
|
326
|
+
先完成你的网站登录,再读取应用已有的会话接口。下面使用与 CMS 一致的 `{authenticated, csrf}` 响应;如果你的后台字段名为 csrfToken,读取它再交给 SDK 的 csrfToken 回调即可。
|
|
486
327
|
|
|
487
|
-
|
|
488
|
-
//
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
328
|
+
```ts
|
|
329
|
+
// Run in your website's existing authenticated browser flow.
|
|
330
|
+
const backendOrigin = 'https://cms-api.example.com'; // YOUR application backend
|
|
331
|
+
const response = await fetch(`${backendOrigin}/api/session`, {
|
|
332
|
+
credentials: 'include', cache: 'no-store', redirect: 'error',
|
|
333
|
+
});
|
|
334
|
+
if (!response.ok) throw new Error('Session unavailable');
|
|
335
|
+
let session = await response.json();
|
|
336
|
+
if (session.authenticated !== true || typeof session.csrf !== 'string' || !session.csrf) {
|
|
337
|
+
throw new Error('Log in to your website first');
|
|
495
338
|
}
|
|
339
|
+
const client = createWebsiteClient(() => session.csrf, backendOrigin);
|
|
340
|
+
// Same origin: createWebsiteClient(() => session.csrf)
|
|
341
|
+
// After token refresh, replace session or update session.csrf.
|
|
342
|
+
// On logout/account/session change, close/unmount old dialogs and recreate client state.
|
|
343
|
+
```
|
|
496
344
|
|
|
497
|
-
|
|
498
|
-
func Create(ctx context.Context, sdk *authlink.Client, saved StoredRequest) (authlink.Ticket, error) {
|
|
499
|
-
return sdk.CreateRequest(ctx, saved.Input, saved.CreateKey)
|
|
500
|
-
}
|
|
345
|
+
`GET /api/session` 是网站已有接口的示例,请按实际路径替换。同源可以使用相对路径和 same-origin Cookie;跨域 Cookie 使用 include,并要求当前前端来源已被业务后台允许。Cookie 策略仍可能限制请求。浏览器无法读取 HttpOnly Cookie,只需要传回当前会话的 CSRF token;不能用手机核对码或浏览器随机值代替。
|
|
501
346
|
|
|
502
|
-
|
|
503
|
-
Status string `json:"status"`
|
|
504
|
-
RequestID string `json:"requestId"`
|
|
505
|
-
VerificationID string `json:"verificationId"`
|
|
506
|
-
Purpose string `json:"purpose"`
|
|
507
|
-
ExpiresAt string `json:"expiresAt"`
|
|
508
|
-
AuthenticationPolicy string `json:"authenticationPolicy"`
|
|
509
|
-
}
|
|
347
|
+
已有 Bearer / JWT 登录的项目可用下列配置,auth 是你的网站登录状态,刷新流程更新 auth.accessToken。此 token 不是 AuthLink 租户密钥。仅在 credentials=omit 的 Bearer 模式可以不传 CSRF;若同时发送 Cookie,仍需 csrfToken。
|
|
510
348
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
result, err := sdk.Consume(ctx, authlink.Expected{
|
|
519
|
-
RequestID: saved.Ticket.RequestID,
|
|
520
|
-
ExternalUserID: saved.Input.ExternalUserID,
|
|
521
|
-
Purpose: saved.Input.Purpose,
|
|
522
|
-
State: saved.Input.State,
|
|
523
|
-
ExpiresAt: saved.Ticket.ExpiresAt,
|
|
524
|
-
}, saved.ConsumeKey)
|
|
525
|
-
if err != nil { return nil, err }
|
|
526
|
-
return PublicConfirmation{
|
|
527
|
-
Status: "confirmed", RequestID: result.RequestID,
|
|
528
|
-
VerificationID: result.VerificationID, Purpose: result.Purpose,
|
|
529
|
-
ExpiresAt: saved.Ticket.ExpiresAt, AuthenticationPolicy: result.AuthenticationPolicy,
|
|
530
|
-
}, nil
|
|
531
|
-
}
|
|
349
|
+
```ts
|
|
350
|
+
const backendOrigin = 'https://cms-api.example.com'; // YOUR application backend
|
|
351
|
+
const client = createAuthLinkClient({
|
|
352
|
+
baseUrl: `${backendOrigin}/api/authlink`, allowedBackendOrigins: [backendOrigin],
|
|
353
|
+
credentials: 'omit', accessToken: () => auth.accessToken,
|
|
354
|
+
authLinkOrigin: 'https://api.getauthlink.com',
|
|
355
|
+
});
|
|
532
356
|
```
|
|
533
357
|
|
|
534
|
-
|
|
358
|
+
### 3. Vue:点击创建、展示和清理
|
|
535
359
|
|
|
536
|
-
|
|
360
|
+
完整文件随包提供为 `examples/vue-client.vue`,可复制为 AuthLinkPanel.vue。父组件在登录后传入当前 session.csrf;跨域时额外传 backendOrigin。退出或切换账号/session 时卸载旧组件,重新挂载新实例;CSRF 刷新可更新 csrf prop。
|
|
537
361
|
|
|
538
362
|
```vue
|
|
539
363
|
<script setup lang="ts">
|
|
540
|
-
import { markRaw,
|
|
541
|
-
import { createAuthLinkClient, requestKey
|
|
364
|
+
import { markRaw, onUnmounted, ref, shallowRef } from 'vue';
|
|
365
|
+
import { createAuthLinkClient, requestKey } from '@auth-link/web';
|
|
542
366
|
import type { Confirmation, Mode, Ticket } from '@auth-link/web';
|
|
543
367
|
import { AuthLinkBind, AuthLinkConfirm } from '@auth-link/web/vue';
|
|
544
368
|
import '@auth-link/web/styles.css';
|
|
545
369
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
// Supply trusted deployment configuration. Mount after site login and remount
|
|
549
|
-
// with :key="session.subject" after login, logout or account/session changes.
|
|
550
|
-
const props = defineProps<{ apiOrigin: string; authLinkOrigin: string; post?: Post }>();
|
|
551
|
-
const session = ref<{ authenticated: boolean; csrf?: string } | null>(null);
|
|
370
|
+
// csrf comes from your website's current authenticated session.
|
|
371
|
+
const props = defineProps<{ csrf: string; backendOrigin?: string }>();
|
|
552
372
|
const ticket = shallowRef<Ticket | null>(null);
|
|
553
|
-
const publication = shallowRef<Post | null>(null);
|
|
554
|
-
const retryPublish = shallowRef<Publication | null>(null);
|
|
555
373
|
const busy = ref(false), message = ref('');
|
|
556
|
-
let creation: { signature: string; key: string } | null = null;
|
|
557
374
|
const lifetime = new AbortController();
|
|
375
|
+
let creationKey: string | null = null;
|
|
376
|
+
let creationMode: Mode | null = null;
|
|
558
377
|
const client = markRaw(createAuthLinkClient({
|
|
559
|
-
baseUrl: `${props.
|
|
560
|
-
allowedBackendOrigins: [props.
|
|
561
|
-
credentials: 'include',
|
|
562
|
-
csrfToken: () =>
|
|
563
|
-
authLinkOrigin:
|
|
378
|
+
baseUrl: props.backendOrigin ? `${props.backendOrigin}/api/authlink` : '/api/authlink',
|
|
379
|
+
allowedBackendOrigins: props.backendOrigin ? [props.backendOrigin] : [],
|
|
380
|
+
credentials: props.backendOrigin ? 'include' : 'same-origin',
|
|
381
|
+
csrfToken: () => props.csrf,
|
|
382
|
+
authLinkOrigin: 'https://api.getauthlink.com',
|
|
564
383
|
}));
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
credentials: 'include', cache: 'no-store', redirect: 'error',
|
|
570
|
-
signal: lifetime.signal,
|
|
571
|
-
headers: body === undefined ? {} : {
|
|
572
|
-
'Content-Type': 'application/json', 'X-CSRF-Token': session.value?.csrf ?? '', ...headers,
|
|
573
|
-
},
|
|
574
|
-
body: body === undefined ? undefined : JSON.stringify(body),
|
|
575
|
-
});
|
|
576
|
-
const value = await response.json();
|
|
577
|
-
if (response.status === 401) {
|
|
578
|
-
session.value = null; closeTicket(); retryPublish.value = null; creation = null;
|
|
579
|
-
}
|
|
580
|
-
if (!response.ok) throw new Error(value.code ?? 'REQUEST_FAILED');
|
|
581
|
-
return value;
|
|
384
|
+
function finish(text: string) { ticket.value = null; message.value = text; }
|
|
385
|
+
function confirmed(result: Confirmation) {
|
|
386
|
+
finish(`${result.purpose}: ${result.requestId}`);
|
|
387
|
+
// Reload your website's account/status API if the page needs fresh state.
|
|
582
388
|
}
|
|
583
|
-
function closeTicket() { ticket.value = null; publication.value = null; }
|
|
584
389
|
function showError(error: Error) { message.value = error.message; }
|
|
585
|
-
function
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
if (busy.value || ticket.value || !session.value?.authenticated) return;
|
|
589
|
-
busy.value = true; message.value = ''; retryPublish.value = null;
|
|
590
|
-
const intent = post ? { id: post.id, version: post.version } : null;
|
|
591
|
-
const signature = JSON.stringify({ purpose, postId: intent?.id, version: intent?.version });
|
|
390
|
+
async function start(mode: Mode) {
|
|
391
|
+
if (busy.value || ticket.value) return;
|
|
392
|
+
busy.value = true; message.value = '';
|
|
592
393
|
try {
|
|
593
|
-
if (
|
|
594
|
-
|
|
595
|
-
const value =
|
|
596
|
-
? validateTicket(await api('/api/authlink/requests', {
|
|
597
|
-
purpose, postId: intent.id, version: intent.version,
|
|
598
|
-
}, { 'Idempotency-Key': creation.key }), purpose, props.authLinkOrigin)
|
|
599
|
-
: await client.create(purpose, { idempotencyKey: creation.key, signal: lifetime.signal });
|
|
394
|
+
if (creationMode !== mode) { creationKey = null; creationMode = mode; }
|
|
395
|
+
creationKey ??= requestKey();
|
|
396
|
+
const value = await client.create(mode, { idempotencyKey: creationKey, signal: lifetime.signal });
|
|
600
397
|
if (lifetime.signal.aborted) return;
|
|
601
|
-
|
|
398
|
+
ticket.value = value; creationKey = null;
|
|
602
399
|
} catch (error) {
|
|
603
400
|
if (!lifetime.signal.aborted) showError(error as Error);
|
|
604
|
-
//
|
|
401
|
+
// Keep the same key when retrying an uncertain creation of the same mode.
|
|
605
402
|
} finally { busy.value = false; }
|
|
606
403
|
}
|
|
607
|
-
|
|
608
|
-
const intent = retryPublish.value;
|
|
609
|
-
if (!intent || busy.value) return;
|
|
610
|
-
busy.value = true;
|
|
611
|
-
try {
|
|
612
|
-
await api(`/api/posts/${intent.id}/publish`, { version: intent.version, requestId: intent.requestId });
|
|
613
|
-
retryPublish.value = null; message.value = 'Published. Reload your posts from the backend.';
|
|
614
|
-
} catch (error) { if (!lifetime.signal.aborted) showError(error as Error); }
|
|
615
|
-
finally { busy.value = false; }
|
|
616
|
-
}
|
|
617
|
-
async function confirmed(result: Confirmation) {
|
|
618
|
-
const intent = publication.value;
|
|
619
|
-
closeTicket();
|
|
620
|
-
if (intent) { retryPublish.value = { ...intent, requestId: result.requestId }; await publish(); }
|
|
621
|
-
else message.value = 'Verified. Reload account status from the backend.';
|
|
622
|
-
}
|
|
623
|
-
onMounted(async () => {
|
|
624
|
-
try {
|
|
625
|
-
const value = await api('/api/session');
|
|
626
|
-
if (value.authenticated !== true || typeof value.csrf !== 'string' || !value.csrf) {
|
|
627
|
-
throw new Error('LOGIN_REQUIRED');
|
|
628
|
-
}
|
|
629
|
-
session.value = value;
|
|
630
|
-
} catch (error) { if (!lifetime.signal.aborted) showError(error as Error); }
|
|
631
|
-
});
|
|
632
|
-
onUnmounted(() => { lifetime.abort(); closeTicket(); creation = null; retryPublish.value = null; });
|
|
404
|
+
onUnmounted(() => { lifetime.abort(); ticket.value = null; creationKey = null; });
|
|
633
405
|
</script>
|
|
634
406
|
|
|
635
407
|
<template>
|
|
636
|
-
<button :disabled="busy || !!ticket
|
|
637
|
-
<button :disabled="busy || !!ticket
|
|
638
|
-
<button v-if="post" :disabled="busy || !!ticket || !session?.authenticated" @click="start('confirm', post)">Confirm publication</button>
|
|
639
|
-
<button v-if="retryPublish" :disabled="busy || !!ticket" @click="publish">Retry the same publication</button>
|
|
408
|
+
<button :disabled="busy || !!ticket" @click="start('bind')">Bind phone</button>
|
|
409
|
+
<button :disabled="busy || !!ticket" @click="start('confirm')">Confirm</button>
|
|
640
410
|
<AuthLinkBind v-if="ticket?.purpose === 'bind'" :ticket="ticket" :client="client" variant="B"
|
|
641
411
|
@confirmed="confirmed" @close="finish('Closed')" @rejected="finish('Rejected')"
|
|
642
|
-
@expired="finish('Expired; start
|
|
412
|
+
@expired="finish('Expired; start again')" @error="showError" />
|
|
643
413
|
<AuthLinkConfirm v-else-if="ticket?.purpose === 'confirm'" :ticket="ticket" :client="client" variant="B"
|
|
644
414
|
@confirmed="confirmed" @close="finish('Closed')" @rejected="finish('Rejected')"
|
|
645
|
-
@expired="finish('Expired; start
|
|
415
|
+
@expired="finish('Expired; start again')" @error="showError" />
|
|
646
416
|
<p role="status">{{ message }}</p>
|
|
647
417
|
</template>
|
|
648
418
|
```
|
|
649
419
|
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
Mounting a dialog observes an existing request; never create from mounted/watch. Clear the ticket on close/rejection/expiry. Leave the dialog mounted on network errors to retry that request. On confirmation, dismiss it and call the independent publish route; preserve the same requestId/post/version if that call fails. Reload actual account/post state from the backend. The sample omits your login form, post list and application error translations.
|
|
653
|
-
|
|
654
|
-
#### 6. Recheck confirmation in the publish transaction
|
|
655
|
-
|
|
656
|
-
The browser confirmed event is not authorization. Go must load the verified receipt for the current session and check purpose=confirm, intent=publish, post ownership, the same postId/version, draft state and expiry on first application. Update the post and mark the intent applied atomically. A retry of that same applied operation returns success without duplicate publication/audit. Another post or edited version requires a new confirmation. Do not add action, transactionHash or document content to the AuthLink receipt.
|
|
657
|
-
|
|
658
|
-
#### Acceptance sequence
|
|
420
|
+
示例使用 shallowRef 保存 ticket、markRaw 保存 client。创建失败保留同一用途的原幂等键;成功后才清空。创建只发生在点击事件中,mounted/watch 不创建请求。确认、拒绝、过期或用户关闭时清空 ticket,卸载组件;网络错误保留弹窗,使用弹窗的重试按钮继续当前请求。需要真实账号状态时重新查询你的网站状态接口。
|
|
659
421
|
|
|
660
|
-
|
|
422
|
+
### 4. React
|
|
661
423
|
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
## 简体中文
|
|
424
|
+
使用上面的初始化方式创建稳定 client,再传给此组件;mode 为 bind 或 confirm。退出或切换账号/session 时卸载旧实例。
|
|
665
425
|
|
|
666
|
-
|
|
426
|
+
```tsx
|
|
427
|
+
"use client";
|
|
428
|
+
import { useEffect, useRef, useState } from 'react';
|
|
429
|
+
import { requestKey } from '@auth-link/web';
|
|
430
|
+
import type { AuthLinkClient, Mode, Ticket } from '@auth-link/web';
|
|
431
|
+
import { AuthLinkBind, AuthLinkConfirm } from '@auth-link/web/react';
|
|
432
|
+
// Import '@auth-link/web/styles.css' once in your application entry.
|
|
667
433
|
|
|
668
|
-
|
|
434
|
+
export function AuthLinkPanel({ client, mode }: { client: AuthLinkClient; mode: Mode }) {
|
|
435
|
+
const [ticket, setTicket] = useState<Ticket | null>(null);
|
|
436
|
+
const [busy, setBusy] = useState(false);
|
|
437
|
+
const [message, setMessage] = useState('');
|
|
438
|
+
const creation = useRef<{ mode: Mode; key: string } | null>(null);
|
|
439
|
+
const inFlight = useRef(false);
|
|
440
|
+
const controller = useRef<AbortController | null>(null);
|
|
441
|
+
useEffect(() => () => { controller.current?.abort(); }, []);
|
|
442
|
+
function finish(text: string) { setTicket(null); setMessage(text); }
|
|
443
|
+
async function start() {
|
|
444
|
+
if (inFlight.current || ticket) return;
|
|
445
|
+
inFlight.current = true; setBusy(true); setMessage('');
|
|
446
|
+
const attempt = new AbortController(); controller.current = attempt;
|
|
447
|
+
try {
|
|
448
|
+
if (creation.current?.mode !== mode) creation.current = { mode, key: requestKey() };
|
|
449
|
+
const value = await client.create(mode, { idempotencyKey: creation.current.key, signal: attempt.signal });
|
|
450
|
+
if (attempt.signal.aborted) return;
|
|
451
|
+
setTicket(value); creation.current = null;
|
|
452
|
+
} catch (error) { if (!attempt.signal.aborted) setMessage((error as Error).message); }
|
|
453
|
+
finally {
|
|
454
|
+
inFlight.current = false;
|
|
455
|
+
if (!attempt.signal.aborted) setBusy(false);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
const Component = mode === 'bind' ? AuthLinkBind : AuthLinkConfirm;
|
|
459
|
+
return <>
|
|
460
|
+
{!ticket && <button disabled={busy} onClick={() => void start()}>Start verification</button>}
|
|
461
|
+
{ticket && <Component ticket={ticket} client={client} variant="B"
|
|
462
|
+
onConfirmed={result => finish(`${result.purpose}: ${result.requestId}`)}
|
|
463
|
+
onClose={() => finish('Closed')} onRejected={() => finish('Rejected')}
|
|
464
|
+
onExpired={() => finish('Expired; start again')}
|
|
465
|
+
onError={error => setMessage(error.message)} />}
|
|
466
|
+
<p role="status">{message}</p>
|
|
467
|
+
</>;
|
|
468
|
+
}
|
|
469
|
+
```
|
|
669
470
|
|
|
670
|
-
|
|
471
|
+
### 5. 原生 JavaScript / 直接调用
|
|
671
472
|
|
|
672
|
-
|
|
473
|
+
```ts
|
|
474
|
+
import { openAuthLinkConfirm, requestKey } from '@auth-link/web';
|
|
475
|
+
import '@auth-link/web/styles.css';
|
|
673
476
|
|
|
477
|
+
// Inside a click handler; client is the stable instance created above.
|
|
478
|
+
const ticket = await client.create('confirm', { idempotencyKey: requestKey() });
|
|
479
|
+
const modal = openAuthLinkConfirm({
|
|
480
|
+
ticket, client, variant: 'B', locale: 'zh-CN',
|
|
481
|
+
onConfirmed: result => console.log(result.requestId),
|
|
482
|
+
onRejected: result => console.log(result.code),
|
|
483
|
+
onExpired: () => console.log('Expired'),
|
|
484
|
+
onError: error => console.log(error.message),
|
|
485
|
+
onClose: () => console.log('Closed'),
|
|
486
|
+
});
|
|
487
|
+
// Route/component cleanup: modal.destroy();
|
|
488
|
+
// Optional: modal.close(); modal.retry(); modal.setLocale('ja'); modal.setVariant('C');
|
|
489
|
+
```
|
|
674
490
|
|
|
675
|
-
|
|
491
|
+
绑定改用 `client.create('bind', ...)` 和 `openAuthLinkBind(...)`。确认或拒绝会先自动关闭再触发对应回调,不触发 onClose。关闭只停止前端观察;页面或组件退出时必须调用 destroy。每个实例对同一请求只通知一次成功;回调用于界面更新,受保护操作仍请求你的网站业务 API。
|
|
676
492
|
|
|
677
|
-
|
|
678
|
-
- **手机确认**:电脑与 App 显示同步变化的六位数字,每 30 秒更新,用户核对后在 App 内批准。
|
|
679
|
-
- **结果通知**:提供确认成功、拒绝、过期和错误事件,方便网站更新界面。
|
|
680
|
-
- **框架支持**:React 组件与 hook、Vue 组件与 composable,以及无框架客户端。
|
|
681
|
-
- **多语言与主题**:支持下表中的 13 种语言,默认简体中文;独立 CSS 和可覆盖的主题变量。
|
|
682
|
-
- **生命周期管理**:请求切换或组件卸载时取消轮询,数字过期后自动隐藏。
|
|
493
|
+
### 6. 前端需要的接口约定
|
|
683
494
|
|
|
684
|
-
|
|
495
|
+
| 网站接口 | 前端发送 | 前端读取 |
|
|
496
|
+
| --- | --- | --- |
|
|
497
|
+
| `POST /api/authlink/requests` | `{purpose:'bind'}` 或 confirm;`Idempotency-Key` | `Ticket` |
|
|
498
|
+
| `POST /api/authlink/requests/{requestId}/result` | `{}`;当前登录凭据 | `Result`,由 SDK 自动轮询 |
|
|
685
499
|
|
|
686
|
-
|
|
500
|
+
Cookie 模式两者均发送当前 CSRF 头。Ticket 包含 requestId、purpose、qr、pairingCode、expiresAt;confirm 还需要 displayCode。直接导入包内 Ticket / Result / Confirmation 类型,不自行生成 QR 内容或动态数字。只有 status=confirmed 触发成功事件;new/pending/approved/consumed 是等待状态,rejected/expired 分别触发对应事件。
|
|
687
501
|
|
|
688
|
-
|
|
689
|
-
2. 安装包、导入样式,创建指向应用后台的客户端。
|
|
690
|
-
3. 用户点击绑定或验证时创建请求,将返回的 ticket 交给 React/Vue 组件。
|
|
691
|
-
4. 用户在 App 确认后,应用后台验证并消费结果,组件收到可信确认事件。
|
|
502
|
+
若你的网站创建接口需要文章 ID、版本等额外参数,SDK create() 只发送 purpose,因此使用自己的 fetch 创建,然后校验 ticket 并交给同一组件。下面的 post、session、backendOrigin 均来自你自己的前端状态;幂等键应保存到重试状态,失败时不要重新生成。
|
|
692
503
|
|
|
693
|
-
|
|
504
|
+
```ts
|
|
505
|
+
import { requestKey, validateTicket } from '@auth-link/web';
|
|
506
|
+
|
|
507
|
+
// Keep this key across retries of the SAME operation and payload.
|
|
508
|
+
const idempotencyKey = requestKey();
|
|
509
|
+
const response = await fetch(`${backendOrigin}/api/authlink/requests`, {
|
|
510
|
+
method: 'POST', credentials: 'include', cache: 'no-store', redirect: 'error',
|
|
511
|
+
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': session.csrf,
|
|
512
|
+
'Idempotency-Key': idempotencyKey },
|
|
513
|
+
body: JSON.stringify({ purpose: 'confirm', postId: post.id, version: post.version }),
|
|
514
|
+
});
|
|
515
|
+
if (!response.ok) throw new Error('Creation failed');
|
|
516
|
+
const ticket = validateTicket(await response.json(), 'confirm', 'https://api.getauthlink.com');
|
|
517
|
+
// Pass ticket and the same client to AuthLinkConfirm.
|
|
518
|
+
```
|
|
694
519
|
|
|
695
|
-
###
|
|
520
|
+
### 7. 参数、事件与样式
|
|
696
521
|
|
|
697
|
-
|
|
522
|
+
| 参数 | 类型 | 含义 |
|
|
523
|
+
| --- | --- | --- |
|
|
524
|
+
| `ticket` | `Ticket` | Required; the current request |
|
|
525
|
+
| `client` | `AuthLinkClient` | Required; keep the instance stable |
|
|
526
|
+
| `locale` | `Locale` | Default `zh-CN` |
|
|
527
|
+
| `variant` | `'A' / 'B' / 'C'` | Default `A` |
|
|
528
|
+
| `showQr` | `boolean` | Optional QR for confirm; bind always shows QR |
|
|
529
|
+
| `intervalMs` | `number` | Default 1500; range 250–10000 ms |
|
|
530
|
+
| `onRefresh` | `() => void` | Optional user action after expiry; create a new ticket |
|
|
531
|
+
| `className` | `string` | Custom dialog class; Vue also accepts `class` |
|
|
532
|
+
|
|
533
|
+
| React 回调 / Vue 事件 | 参数 | 行为 |
|
|
534
|
+
| --- | --- | --- |
|
|
535
|
+
| `onConfirmed` / `confirmed` | `Confirmation` | Success; dialog closes automatically |
|
|
536
|
+
| `onRejected` / `rejected` | `Rejection` | `{status:'rejected',code:'REJECTED',requestId,purpose}`; dialog closes |
|
|
537
|
+
| `onExpired` / `expired` | — | Request expired |
|
|
538
|
+
| `onClose` / `close` | — | User closes the dialog; clear ticket |
|
|
539
|
+
| `onError` / `error` | `Error` | Read `error.message`; retain the dialog for retry |
|
|
698
540
|
|
|
699
|
-
|
|
541
|
+
A 为默认居中卡片;B 为桌面分栏、窄屏纵排;C 为紧凑卡片、手机底部弹层。切换 locale/variant 不重新创建请求。原生 dialog.showModal() 浏览器支持是必需的。
|
|
700
542
|
|
|
701
|
-
|
|
543
|
+
CSS 需单独导入。弹窗挂到 document.body,主题变量放在 :root 或 .authlink-dialog:`--authlink-foreground`、`--authlink-background`、`--authlink-accent`、`--authlink-muted`。Vue 的 class 支持字符串、数组和对象,并与 className 合并。SSR 不展示弹窗或创建请求。
|
|
702
544
|
|
|
703
|
-
|
|
545
|
+
语言:`zh-CN` (default), `zh-TW`, `en`, `ja`, `bn`, `es`, `fil`, `hi`, `id`, `ms`, `pt`, `th`, `vi`。未指定或不支持时回退简体中文,可导入 supportedLocales 获取列表。
|
|
704
546
|
|
|
705
|
-
|
|
547
|
+
### 8. 传输配置与排错
|
|
706
548
|
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
549
|
+
| 配置 | 用途 |
|
|
550
|
+
| --- | --- |
|
|
551
|
+
| `baseUrl` | Your application API prefix; default `/api/authlink` |
|
|
552
|
+
| `allowedBackendOrigins` | Exact extra application API origins; no paths/wildcards |
|
|
553
|
+
| `credentials` | Default `same-origin`; cross-origin cookies need `include` |
|
|
554
|
+
| `csrfToken` | Sync/async getter for the current website CSRF token |
|
|
555
|
+
| `csrfHeaderName` | Default `X-CSRF-Token` |
|
|
556
|
+
| `accessToken` | Sync/async website Bearer token getter, without `Bearer ` |
|
|
557
|
+
| `headers` | Extra headers or sync/async getter |
|
|
558
|
+
| `authLinkOrigin` | `https://api.getauthlink.com` for production AuthLink |
|
|
559
|
+
| `fetch` | Optional fetch implementation |
|
|
710
560
|
|
|
711
|
-
|
|
561
|
+
自定义 headers 不能覆盖 Accept、Content-Type、Authorization、Idempotency-Key 或配置的 CSRF 头。错误信息可从 error.message 读取:CSRF_REQUIRED 检查当前 token;ACCESS_TOKEN_REQUIRED 检查登录 token;BACKEND_ORIGIN_NOT_ALLOWED 检查完整来源允许列表;NETWORK_ERROR 检查浏览器 Network 面板、网络和 CORS。SDK 请求超时为 10 秒,关闭/卸载会取消观察。
|
|
712
562
|
|
|
713
|
-
|
|
563
|
+
数字过期会隐藏,不在前端生成或缓存新数字;二维码在浏览器本地生成。若页面启用 CSP,图片来源需要允许 data:。确认回调、localStorage 或浏览器自报状态都不作为业务授权依据。
|
|
714
564
|
|
|
715
|
-
|
|
565
|
+
### 许可证
|
|
716
566
|
|
|
717
|
-
|
|
718
|
-
2. 服务端使用当前 session 的 token 渲染下面的标签,正确转义 HTML 属性;`{{ csrfToken }}` 表示服务器模板变量,不是可直接照抄的 token。包含会话 token 的页面不能被公开缓存。
|
|
719
|
-
3. 创建和查询接口都读取 `X-CSRF-Token`,与同一登录 session 保存的 token 校验;缺失、不匹配或请求来源不符合网站要求时拒绝请求。仅让前端发送这个头,并不会自动启用后台校验。
|
|
720
|
-
4. 登录、退出或会话/token 更新后,让页面重新取得当前 token。SPA 也可以由网站实现同源会话初始化接口返回 token,再通过 `csrfToken` 回调提供;这不是 SDK 自带接口。
|
|
567
|
+
MIT。完整文本见 LICENSE,第三方归属见 THIRD_PARTY_NOTICES.md。
|
|
721
568
|
|
|
722
|
-
|
|
723
|
-
<!-- 由你的网站后台渲染到当前用户的页面 head 中 -->
|
|
724
|
-
<meta name="csrf-token" content="{{ csrfToken }}">
|
|
725
|
-
```
|
|
569
|
+
---
|
|
726
570
|
|
|
727
|
-
|
|
571
|
+
## 日本語
|
|
728
572
|
|
|
729
|
-
|
|
730
|
-
// authlink-client.ts
|
|
731
|
-
import { createAuthLinkClient } from '@auth-link/web';
|
|
732
|
-
import '@auth-link/web/styles.css';
|
|
573
|
+
スマートフォン登録とアプリ内確認のフロントエンド用ダイアログです。Vue 3、React 18/19、通常の JavaScript に対応します。インストール、session 取得、client 初期化、表示、イベントと後処理の順に接続します。
|
|
733
574
|
|
|
734
|
-
|
|
735
|
-
// CSRF token. This function only reads that token; it does not generate one.
|
|
736
|
-
export function readCsrfTokenFromYourCms(): string {
|
|
737
|
-
const token = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content;
|
|
738
|
-
if (!token) throw new Error('Missing CSRF token; load the authenticated application page first.');
|
|
739
|
-
return token;
|
|
740
|
-
}
|
|
575
|
+
### 1. インストールと 2 つの API
|
|
741
576
|
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
// Replace this example with the configured AuthLink service origin.
|
|
746
|
-
authLinkOrigin: 'https://auth.example.com',
|
|
747
|
-
});
|
|
577
|
+
```sh
|
|
578
|
+
npm install @auth-link/web@latest
|
|
579
|
+
# 利用する vue、または react / react-dom もインストールします。
|
|
748
580
|
```
|
|
749
581
|
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
### 前后端分离与跨域后台(0.0.2 起)
|
|
755
|
-
|
|
756
|
-
默认仍使用同源 `/api/authlink`。直接访问另一个后台来源时,同时配置完整 `baseUrl` 和 `allowedBackendOrigins`,例如网页在 `https://www.example.com`,业务后台在 `https://api.example.com`。来源是协议、主机和端口的组合;同一主域名下不同子域名也属于跨域。
|
|
757
|
-
|
|
758
|
-
`allowedBackendOrigins` 是前端明确授权的**业务后台来源**,不能带路径、通配符、用户名或密码,不能来自用户输入。生产环境使用 HTTPS;HTTP 仅允许 localhost / 127.0.0.1 / [::1] 开发环境,端口必须匹配。SDK 会先校验目标再读取凭据,并拒绝 HTTP 重定向。`authLinkOrigin` 则用于校验二维码中的 AuthLink 服务来源,两者作用不同,可按需要同时配置。
|
|
582
|
+
| origin | フロントエンド設定 | 用途 |
|
|
583
|
+
| --- | --- | --- |
|
|
584
|
+
| 自サイトの API(例 https://cms-api.example.com) | baseUrl、allowedBackendOrigins | session、要求作成、結果 |
|
|
585
|
+
| AuthLink 本番 API https://api.getauthlink.com | authLinkOrigin | ticket 内のサービス origin の照合 |
|
|
759
586
|
|
|
760
|
-
|
|
587
|
+
baseUrl は自サイトの業務 API であり、AuthLink API や公式サイトではありません。既存のログイン/session と下記要求 API を利用します。SDK 自体はログイン/session API を提供しません。テナントキーはブラウザーに渡しません。
|
|
761
588
|
|
|
762
|
-
|
|
589
|
+
ルート import は client と型、/vue と /react はコンポーネントと useAuthLink、/styles.css は明示 import です。ESM、TypeScript、ツール環境 Node 22.13+、React 18.2+/19 または Vue 3.3+。利用するフレームワークのみ導入します。
|
|
763
590
|
|
|
764
|
-
|
|
591
|
+
### 2. 現在の session と client
|
|
765
592
|
|
|
766
593
|
```ts
|
|
767
594
|
import { createAuthLinkClient } from '@auth-link/web';
|
|
595
|
+
import '@auth-link/web/styles.css';
|
|
768
596
|
|
|
769
|
-
|
|
770
|
-
export
|
|
771
|
-
|
|
772
|
-
|
|
597
|
+
// getCsrf reads your website's CURRENT session token on every request.
|
|
598
|
+
export function createWebsiteClient(getCsrf: () => string, backendOrigin?: string) {
|
|
599
|
+
return createAuthLinkClient({
|
|
600
|
+
baseUrl: backendOrigin ? `${backendOrigin}/api/authlink` : '/api/authlink',
|
|
601
|
+
allowedBackendOrigins: backendOrigin ? [backendOrigin] : [],
|
|
602
|
+
credentials: backendOrigin ? 'include' : 'same-origin',
|
|
603
|
+
csrfToken: getCsrf,
|
|
604
|
+
authLinkOrigin: 'https://api.getauthlink.com',
|
|
773
605
|
});
|
|
774
|
-
if (!response.ok) throw new Error('Session initialization failed');
|
|
775
|
-
const session: unknown = await response.json();
|
|
776
|
-
if (!session || typeof session !== 'object' || !('csrfToken' in session) ||
|
|
777
|
-
typeof session.csrfToken !== 'string' || !session.csrfToken) {
|
|
778
|
-
throw new Error('The session endpoint must return { csrfToken: string }');
|
|
779
|
-
}
|
|
780
|
-
let currentCsrfToken = session.csrfToken;
|
|
781
|
-
return {
|
|
782
|
-
client: createAuthLinkClient({
|
|
783
|
-
baseUrl: `${backendOrigin}/api/authlink`,
|
|
784
|
-
allowedBackendOrigins: [backendOrigin],
|
|
785
|
-
credentials: 'include',
|
|
786
|
-
csrfToken: () => currentCsrfToken,
|
|
787
|
-
}),
|
|
788
|
-
// Call after your application's session/token refresh; recreate on account changes.
|
|
789
|
-
updateCsrfToken(value: string) { currentCsrfToken = value; },
|
|
790
|
-
};
|
|
791
606
|
}
|
|
792
607
|
```
|
|
793
608
|
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
#### 跨域 Bearer token / JWT
|
|
797
|
-
|
|
798
|
-
传入网站自己的登录状态对象;网站完成登录或刷新后更新这个对象的 `accessToken`。回调会在每次创建和轮询时重新读取最新 token,无需重建弹窗。
|
|
609
|
+
サイトのログイン後に既存 session API を読みます。例は CMS の authenticated/csrf です。フィールド名が csrfToken ならその値を SDK の csrfToken コールバックへ渡します。
|
|
799
610
|
|
|
800
611
|
```ts
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
});
|
|
612
|
+
// Run in your website's existing authenticated browser flow.
|
|
613
|
+
const backendOrigin = 'https://cms-api.example.com'; // YOUR application backend
|
|
614
|
+
const response = await fetch(`${backendOrigin}/api/session`, {
|
|
615
|
+
credentials: 'include', cache: 'no-store', redirect: 'error',
|
|
616
|
+
});
|
|
617
|
+
if (!response.ok) throw new Error('Session unavailable');
|
|
618
|
+
let session = await response.json();
|
|
619
|
+
if (session.authenticated !== true || typeof session.csrf !== 'string' || !session.csrf) {
|
|
620
|
+
throw new Error('Log in to your website first');
|
|
811
621
|
}
|
|
622
|
+
const client = createWebsiteClient(() => session.csrf, backendOrigin);
|
|
623
|
+
// Same origin: createWebsiteClient(() => session.csrf)
|
|
624
|
+
// After token refresh, replace session or update session.csrf.
|
|
625
|
+
// On logout/account/session change, close/unmount old dialogs and recreate client state.
|
|
812
626
|
```
|
|
813
627
|
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
#### 后台 CORS 配置
|
|
817
|
-
|
|
818
|
-
后台需处理浏览器发出的 `OPTIONS` 预检,并在预检、实际响应和错误响应中返回相应 CORS 头。会话初始化接口也需要这些设置。以下假设前端来源是 `https://www.example.com`:
|
|
819
|
-
|
|
820
|
-
```http
|
|
821
|
-
Access-Control-Allow-Origin: https://www.example.com
|
|
822
|
-
Access-Control-Allow-Credentials: true
|
|
823
|
-
Access-Control-Allow-Methods: GET, POST, OPTIONS
|
|
824
|
-
Access-Control-Allow-Headers: Content-Type, X-CSRF-Token, Idempotency-Key, Authorization
|
|
825
|
-
Vary: Origin
|
|
826
|
-
```
|
|
827
|
-
|
|
828
|
-
携带 Cookie 时不能把 `Access-Control-Allow-Origin` 配为 `*`;多个允许来源应匹配白名单后返回当前来源,不能无条件反射任意 Origin。预检通常不携带登录凭据,因此先验证 Origin、请求方法和请求头,再对实际请求验证登录身份、CSRF(Cookie 模式)和归属。若改了 CSRF 头名或添加自定义头,也要加入 `Access-Control-Allow-Headers`。SDK 不能替后台开启 CORS,也不能用 `no-cors` 读取验证结果。见 [MDN CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS)。
|
|
829
|
-
|
|
830
|
-
#### 请求客户端配置
|
|
831
|
-
|
|
832
|
-
| 配置 | 说明 |
|
|
833
|
-
| --- | --- |
|
|
834
|
-
| `baseUrl` | 业务后台前缀,默认 `/api/authlink`;跨域时填写完整地址 |
|
|
835
|
-
| `allowedBackendOrigins` | 额外允许的精确后台来源数组,默认只允许当前网页同源 |
|
|
836
|
-
| `credentials` | Cookie 模式默认 `same-origin`;提供 `accessToken` 时默认 `omit`;跨域 Cookie 显式设置 `include` |
|
|
837
|
-
| `csrfToken` | 每次请求读取 CSRF token 的同步或异步函数;Cookie 模式必填 |
|
|
838
|
-
| `csrfHeaderName` | CSRF 请求头名,默认 `X-CSRF-Token`,例如可设为 `X-XSRF-TOKEN` |
|
|
839
|
-
| `accessToken` | 每次请求读取网站 Bearer token 的同步或异步函数;不要包含 `Bearer ` 前缀 |
|
|
840
|
-
| `headers` | 额外的 `HeadersInit` 或返回它的同步/异步函数,可传工作区等自定义头 |
|
|
841
|
-
| `authLinkOrigin` | 核对二维码中的 AuthLink 服务来源,与业务后台来源独立 |
|
|
842
|
-
| `fetch` | 可选 fetch 实现;仍受目标来源和协议校验约束 |
|
|
843
|
-
|
|
844
|
-
SDK 管理 `Accept`、`Content-Type`、`Authorization`、`Idempotency-Key` 和配置的 CSRF 头,`headers` 不能覆盖这些字段,否则报 `RESERVED_HEADER`。用 `accessToken` 设置 Bearer token,用 `csrfHeaderName` 适配后台框架。`ClientOptions` 可从根入口导入为 TypeScript 类型。
|
|
845
|
-
|
|
846
|
-
未授权来源报 `BACKEND_ORIGIN_NOT_ALLOWED`(替代旧版的 `CMS_ORIGIN_REQUIRED`),非法来源/地址分别报 `INVALID_BACKEND_ORIGIN` / `INVALID_BASE_URL`;缺少当前登录 token 报 `ACCESS_TOKEN_REQUIRED`,缺少必要 CSRF 报 `CSRF_REQUIRED`。凭据读取也受请求取消和 10 秒超时控制。网络层的 CORS 错误由浏览器拦截,SDK 通常只能报告 `NETWORK_ERROR`,具体原因需查看浏览器开发者工具。
|
|
628
|
+
GET /api/session はサイト側の既存ルートの例です。同 origin は相対 URL と same-origin Cookie、別 origin の Cookie は include を使い、業務 API が現在のフロントエンド origin を許可している必要があります。Cookie 制限も適用されます。HttpOnly Cookie を読んだり、CSRF をブラウザーで生成したり、照合数字で代用したりしません。
|
|
847
629
|
|
|
848
|
-
|
|
630
|
+
Bearer/JWT を使う場合、auth は既存ログイン状態です。更新された accessToken を毎回読みます。これはテナントキーではありません。credentials=omit の Bearer のみ CSRF を省略でき、Cookie 併用なら csrfToken が必要です。
|
|
849
631
|
|
|
850
632
|
```ts
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
const ticket = await client.create('confirm', { idempotencyKey: requestKey() });
|
|
857
|
-
const modal = openAuthLinkConfirm({
|
|
858
|
-
ticket, client, locale: 'zh-CN',
|
|
859
|
-
onConfirmed: result => console.log(result.requestId),
|
|
860
|
-
onRejected: result => console.log(result.code), // REJECTED,此时弹窗已关闭
|
|
861
|
-
onClose: () => console.log('已关闭'),
|
|
633
|
+
const backendOrigin = 'https://cms-api.example.com'; // YOUR application backend
|
|
634
|
+
const client = createAuthLinkClient({
|
|
635
|
+
baseUrl: `${backendOrigin}/api/authlink`, allowedBackendOrigins: [backendOrigin],
|
|
636
|
+
credentials: 'omit', accessToken: () => auth.accessToken,
|
|
637
|
+
authLinkOrigin: 'https://api.getauthlink.com',
|
|
862
638
|
});
|
|
863
|
-
// 绑定使用 openAuthLinkBind({ ticket: bindingTicket, client })。
|
|
864
|
-
// modal.close(); modal.setLocale('ja');
|
|
865
639
|
```
|
|
866
640
|
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
关闭只停止前端查询,不拒绝或撤销服务端请求;请求仍按原有效期结束。确认成功或拒绝后弹窗自动关闭,分别回调 onConfirmed 或 onRejected,不触发用户关闭回调。直接调用时,页面卸载须执行 modal.destroy() 清理,它不触发用户关闭回调。切换语言用 modal.setLocale(),不会重建鉴权请求。
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
### React
|
|
641
|
+
### 3. Vue:クリックで作成、表示、後処理
|
|
873
642
|
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
这个示例包含按钮、请求创建、结果提示和弹窗清理,不再依赖未定义的 `showBound`、`showRejected` 等函数。使用 `<AuthLinkPanel mode="bind" />` 绑定,使用 `<AuthLinkPanel mode="confirm" />` 请求手机确认。
|
|
877
|
-
|
|
878
|
-
```tsx
|
|
879
|
-
"use client";
|
|
880
|
-
import { useRef, useState } from 'react';
|
|
881
|
-
import { requestKey } from '@auth-link/web';
|
|
882
|
-
import type { Mode, Ticket } from '@auth-link/web';
|
|
883
|
-
import { AuthLinkBind, AuthLinkConfirm } from '@auth-link/web/react';
|
|
884
|
-
import { client } from './authlink-client';
|
|
885
|
-
|
|
886
|
-
export function AuthLinkPanel({ mode }: { mode: Mode }) {
|
|
887
|
-
const [ticket, setTicket] = useState<Ticket | null>(null);
|
|
888
|
-
const [busy, setBusy] = useState(false);
|
|
889
|
-
const [message, setMessage] = useState('');
|
|
890
|
-
const key = useRef<string | null>(null);
|
|
891
|
-
const creating = useRef(false);
|
|
892
|
-
function finish(message: string) {
|
|
893
|
-
setTicket(null);
|
|
894
|
-
setMessage(message);
|
|
895
|
-
}
|
|
896
|
-
function showBound(requestId: string) {
|
|
897
|
-
// UI feedback only. The backend has already verified and saved the binding.
|
|
898
|
-
finish(`手机绑定成功(请求 ${requestId})`);
|
|
899
|
-
}
|
|
900
|
-
function showConfirmed(requestId: string) {
|
|
901
|
-
finish(`手机确认成功(请求 ${requestId})`);
|
|
902
|
-
}
|
|
903
|
-
const start = async () => {
|
|
904
|
-
if (creating.current || ticket) return;
|
|
905
|
-
creating.current = true;
|
|
906
|
-
setBusy(true);
|
|
907
|
-
setMessage('');
|
|
908
|
-
try {
|
|
909
|
-
key.current ??= requestKey();
|
|
910
|
-
setTicket(await client.create(mode, { idempotencyKey: key.current }));
|
|
911
|
-
key.current = null;
|
|
912
|
-
} catch { setMessage('请求未完成,可重试同一次创建。'); }
|
|
913
|
-
finally { creating.current = false; setBusy(false); }
|
|
914
|
-
};
|
|
915
|
-
const Component = mode === 'bind' ? AuthLinkBind : AuthLinkConfirm;
|
|
916
|
-
return <>
|
|
917
|
-
{!ticket && <button type="button" disabled={busy} onClick={() => void start()}>开始验证</button>}
|
|
918
|
-
{ticket && <Component ticket={ticket} client={client}
|
|
919
|
-
onConfirmed={result => result.purpose === 'bind' ? showBound(result.requestId) : showConfirmed(result.requestId)}
|
|
920
|
-
onClose={() => finish('已关闭')}
|
|
921
|
-
onRejected={() => finish('手机已拒绝本次请求')}
|
|
922
|
-
onExpired={() => finish('请求已过期,请重新发起')}
|
|
923
|
-
onError={error => setMessage(error.message)} />}
|
|
924
|
-
<p role="status">{message}</p>
|
|
925
|
-
</>;
|
|
926
|
-
}
|
|
927
|
-
```
|
|
928
|
-
|
|
929
|
-
`requestId` 用于展示或追踪本次请求,不是授权凭据。`showBound` 也可以按网站需求显示 toast、重新查询已有的账号状态接口或导航到绑定管理页。刷新页面后,应重新从后台读取真实绑定状态,不能只依赖 React state 或 localStorage。其他业务接口仍需在后台检查权限。
|
|
930
|
-
|
|
931
|
-
创建失败时示例保留同一个幂等键;再次点击会重试同一次创建。创建成功后清空幂等键,新的用户操作才创建新请求。关闭、成功、拒绝和过期都会清空 ticket;网络错误保留弹窗,供其重试当前请求。
|
|
932
|
-
|
|
933
|
-
确认组件默认只显示动态数字;`showQr` 可额外显示扫码入口。绑定组件始终显示二维码。`onRefresh` 可提供重新创建请求的函数;仅过期后显示该按钮。组件自身的“重试”只恢复当前请求,不创建第二个请求。
|
|
934
|
-
|
|
935
|
-
未指定 `locale` 时使用简体中文;不支持的语言代码也回退到简体中文。组件根元素设置对应的 `lang`,语言切换不会重新创建鉴权请求。
|
|
936
|
-
|
|
937
|
-
React 入口保留 `"use client"`,可用于 Next.js 客户端组件。导入包和 SSR 渲染不会创建请求。将 client 与 ticket 保持为稳定对象,例如模块级 client 和 React state 中的 ticket。卸载/切换 ticket 会取消旧轮询;StrictMode 不会创建额外请求。
|
|
938
|
-
|
|
939
|
-
### Vue
|
|
940
|
-
|
|
941
|
-
```vue
|
|
942
|
-
<script setup lang="ts">
|
|
943
|
-
import { ref, shallowRef } from 'vue';
|
|
944
|
-
import { requestKey } from '@auth-link/web';
|
|
945
|
-
import type { Confirmation, Mode, Ticket } from '@auth-link/web';
|
|
946
|
-
import { AuthLinkBind, AuthLinkConfirm } from '@auth-link/web/vue';
|
|
947
|
-
import { client } from './authlink-client';
|
|
948
|
-
const props = defineProps<{ mode: Mode }>();
|
|
949
|
-
const ticket = shallowRef<Ticket | null>(null);
|
|
950
|
-
const busy = ref(false);
|
|
951
|
-
const message = ref('');
|
|
952
|
-
let key: string | null = null;
|
|
953
|
-
function finish(text: string) { ticket.value = null; message.value = text; }
|
|
954
|
-
function confirmed(result: Confirmation) {
|
|
955
|
-
finish(`${result.purpose === 'bind' ? '已绑定' : '已确认'}(请求 ${result.requestId})`);
|
|
956
|
-
}
|
|
957
|
-
async function start() {
|
|
958
|
-
if (busy.value) return;
|
|
959
|
-
busy.value = true;
|
|
960
|
-
key ??= requestKey();
|
|
961
|
-
try {
|
|
962
|
-
ticket.value = await client.create(props.mode, { idempotencyKey: key });
|
|
963
|
-
key = null;
|
|
964
|
-
} catch { message.value = '请求未完成,可重试同一次创建。'; }
|
|
965
|
-
finally { busy.value = false; }
|
|
966
|
-
}
|
|
967
|
-
</script>
|
|
968
|
-
|
|
969
|
-
<template>
|
|
970
|
-
<button v-if="!ticket" type="button" :disabled="busy" @click="start">开始验证</button>
|
|
971
|
-
<AuthLinkBind v-else-if="mode === 'bind'" :ticket="ticket" :client="client"
|
|
972
|
-
@confirmed="confirmed" @close="finish('已关闭')" @rejected="finish('手机已拒绝本次请求')"
|
|
973
|
-
@expired="finish('请求已过期,请重新发起')" @error="message = '网络异常,请重试。'" />
|
|
974
|
-
<AuthLinkConfirm v-else :ticket="ticket" :client="client"
|
|
975
|
-
@confirmed="confirmed" @close="finish('已关闭')" @rejected="finish('手机已拒绝本次请求')"
|
|
976
|
-
@expired="finish('请求已过期,请重新发起')" @error="message = '网络异常,请重试。'" />
|
|
977
|
-
<p role="status">{{ message }}</p>
|
|
978
|
-
</template>
|
|
979
|
-
```
|
|
980
|
-
|
|
981
|
-
拒绝时返回 `{ status: 'rejected', code: 'REJECTED', requestId, purpose }`。`REJECTED` 是 SDK 业务状态码,不是 HTTP 状态码或授权凭证。组件自动关闭,不再显示“已拒绝 / 重新发起”面板,也不触发 `onClose`;提示和重新发起由接入网站在回调中决定。React / Vue 请在 `onRejected` / `@rejected` 中清空 ticket、卸载组件。无 UI 的 hook 仍保留 `snapshot.status === 'rejected'`,供自定义界面使用。
|
|
982
|
-
|
|
983
|
-
此示例在过期后清空 ticket,用户可再次点击按钮发起请求。如果需要在过期弹窗内提供重新发起按钮,可自行传入 `onRefresh`,在回调中创建新请求并更新 ticket。`useAuthLink(() => options)` composable 与 React 的 `useAuthLink(options)` hook 可用于自定义界面,返回 `snapshot` 和 `retry`;Vue 的 snapshot 是浅 ref。
|
|
984
|
-
|
|
985
|
-
### 选择弹窗样式
|
|
986
|
-
|
|
987
|
-
初始化时通过 `variant` 选择样式,**不传时默认 A**。
|
|
988
|
-
|
|
989
|
-
| 参数值 | 电脑端 | 手机端 |
|
|
990
|
-
| --- | --- | --- |
|
|
991
|
-
| `A`(默认) | 极简居中卡片、细线数字更新进度条 | 居中卡片 |
|
|
992
|
-
| `B` | 二维码和核对码左右分栏 | 自动纵向排列 |
|
|
993
|
-
| `C` | 紧凑卡片、底部取消入口 | 底部弹层,适配安全区域 |
|
|
994
|
-
|
|
995
|
-
```ts
|
|
996
|
-
openAuthLinkBind({ ticket, client }); // 默认 A、简体中文
|
|
997
|
-
openAuthLinkConfirm({ ticket, client, variant: 'B' });
|
|
998
|
-
// modal.setVariant('C'); // 只更新显示,不重建请求、不重启查询
|
|
999
|
-
```
|
|
1000
|
-
|
|
1001
|
-
```tsx
|
|
1002
|
-
<AuthLinkBind ticket={ticket} client={client} variant="C" />
|
|
1003
|
-
```
|
|
1004
|
-
|
|
1005
|
-
```vue
|
|
1006
|
-
<AuthLinkConfirm :ticket="ticket" :client="client" variant="B" />
|
|
1007
|
-
```
|
|
1008
|
-
|
|
1009
|
-
三套样式统一使用细进度条和固定文案“核对码每 30 秒自动更新”,不显示数字更新秒数。底部通常显示“等待手机确认”;仅最后 30 秒改为柔和橙色“请求即将过期 · 00:29”。新核对码轻微淡入,遵守系统减少动态效果设置;数字刷新不延长请求有效期。
|
|
1010
|
-
|
|
1011
|
-
三套样式使用相同的鉴权、过期和回调逻辑。React / Vue 可响应式切换 `variant`,不会重新发起请求;无效的运行时值回退到 A。B / C 的底部“取消”和右上角关闭行为一致,仅停止前端观察,不会替用户拒绝服务端请求。确认成功仍自动关闭。关闭图标保留 44px 点击区域和键盘焦点提示。
|
|
1012
|
-
|
|
1013
|
-
### 组件参数与事件
|
|
1014
|
-
|
|
1015
|
-
`AuthLinkBind` 用于绑定二维码,`AuthLinkConfirm` 用于手机确认。两者使用相同的主要参数:
|
|
1016
|
-
|
|
1017
|
-
| 参数 | 类型 | 说明 |
|
|
1018
|
-
| --- | --- | --- |
|
|
1019
|
-
| `ticket` | `Ticket`,必填 |应用后台创建的当前请求;组件不会自行创建请求 |
|
|
1020
|
-
| `client` | `AuthLinkClient`,必填 | 查询应用后台结果的客户端 |
|
|
1021
|
-
| `locale` | `Locale`,见下表 | 界面语言,默认 `zh-CN` |
|
|
1022
|
-
| `variant` | `DialogVariant` | `A`(默认)、`B` 或 `C`;只改变布局 |
|
|
1023
|
-
| `showQr` | `boolean` | 确认组件是否额外显示二维码;绑定组件始终显示 |
|
|
1024
|
-
| `intervalMs` | `number` | 结果轮询间隔,默认 1500 毫秒 |
|
|
1025
|
-
| `onRefresh` | `() => void` | 过期后,由网站重新创建请求并更新 ticket |
|
|
1026
|
-
|
|
1027
|
-
| React 回调 / Vue 事件 | 参数 | 触发时机 |
|
|
1028
|
-
| --- | --- | --- |
|
|
1029
|
-
| `onConfirmed` / `confirmed` | `Confirmation` |应用后台完成结果验证和消费后 |
|
|
1030
|
-
| `onRejected` / `rejected` | `Rejection` | 手机拒绝请求,弹窗自动关闭 |
|
|
1031
|
-
| `onExpired` / `expired` | 无 | 请求已过期 |
|
|
1032
|
-
| `onError` / `error` | `Error` | 网络错误、响应校验失败或成功回调执行失败等 |
|
|
1033
|
-
|
|
1034
|
-
业务处理以 `requestId` / `verificationId` 做持久化幂等。前端成功事件用于展示状态,不能作为后台接口放行凭据。
|
|
1035
|
-
|
|
1036
|
-
### 导入入口
|
|
1037
|
-
|
|
1038
|
-
| 入口 | 用途 |
|
|
1039
|
-
| --- | --- |
|
|
1040
|
-
| `@auth-link/web` | `createAuthLinkClient`、`requestKey`、公开类型及无框架工具 |
|
|
1041
|
-
| `@auth-link/web/react` | React 组件和 `useAuthLink` |
|
|
1042
|
-
| `@auth-link/web/vue` | Vue 组件和 `useAuthLink` |
|
|
1043
|
-
| `@auth-link/web/styles.css` | 组件样式,需显式导入 |
|
|
1044
|
-
|
|
1045
|
-
### 应用后台接口约定
|
|
1046
|
-
|
|
1047
|
-
默认前缀 `/api/authlink`;下面两个接口由接入方实现,**不是直接把浏览器请求转发到租户密钥 API**。
|
|
1048
|
-
|
|
1049
|
-
#### `POST /requests`
|
|
1050
|
-
|
|
1051
|
-
浏览器按所选模式携带网站登录凭据:Cookie 模式发送 session cookie 和配置的 CSRF 头,Bearer 模式发送 `Authorization`。创建还发送稳定的 `Idempotency-Key`,body:
|
|
1052
|
-
|
|
1053
|
-
```json
|
|
1054
|
-
{"purpose":"bind"}
|
|
1055
|
-
```
|
|
1056
|
-
|
|
1057
|
-
后台从经过验证的 session 或 Bearer token 取得用户 ID,创建并持久保存 state、创建/消费幂等键、用户/session 关联,然后调用 AuthLink 服务端创建请求。返回其公开 ticket:
|
|
1058
|
-
|
|
1059
|
-
```ts
|
|
1060
|
-
type Ticket = {
|
|
1061
|
-
requestId: string;
|
|
1062
|
-
purpose: 'bind' | 'confirm';
|
|
1063
|
-
qr: string; // AuthLink 返回的 QR JSON,不是 HTML
|
|
1064
|
-
pairingCode: string;
|
|
1065
|
-
expiresAt: string;
|
|
1066
|
-
displayCode: {
|
|
1067
|
-
value: string; // 六位数字,保留前导零
|
|
1068
|
-
serverTime: string; validUntil: string;
|
|
1069
|
-
periodSeconds: 30;
|
|
1070
|
-
};
|
|
1071
|
-
};
|
|
1072
|
-
```
|
|
1073
|
-
|
|
1074
|
-
#### `POST /requests/{requestId}/result`
|
|
1075
|
-
|
|
1076
|
-
同样验证登录身份(Cookie 模式还需 CSRF)和请求归属。后台调用 AuthLink 状态接口,将 `displayCode` 原样返回,不能缓存跨过 `validUntil`。新代码由后台实时获取,不是前端自行生成。
|
|
1077
|
-
|
|
1078
|
-
尚未完成时返回 `{requestId,status,displayCode?}`;状态可为 new / pending / approved / consumed / rejected / expired。`approved` 不能触发成功事件。
|
|
1079
|
-
|
|
1080
|
-
收到 approved / consumed 后,后台使用保存的稳定消费幂等键调用 `/consume`,核对完整结果,在自己数据库事务中记录此次 session 的确认,再返回已验证的回执。组件确认所需最小字段:
|
|
1081
|
-
|
|
1082
|
-
```json
|
|
1083
|
-
{
|
|
1084
|
-
"status":"confirmed",
|
|
1085
|
-
"requestId":"43字符请求ID",
|
|
1086
|
-
"verificationId":"43字符结果ID",
|
|
1087
|
-
"purpose":"confirm",
|
|
1088
|
-
"authenticationPolicy":"device-presence.v1",
|
|
1089
|
-
"expiresAt":"与创建请求完全一致的期限"
|
|
1090
|
-
}
|
|
1091
|
-
```
|
|
1092
|
-
|
|
1093
|
-
服务端必须在同一事务内记录已验证的确认结果,并校验请求归属。网页事件只用于更新界面;受保护接口必须依赖后台的可信确认记录,不能依赖 JS 回调或浏览器传来的 confirmed。
|
|
1094
|
-
|
|
1095
|
-
### 数字、时钟与安全
|
|
1096
|
-
|
|
1097
|
-
- 每个请求独立派生数字生成 seed;seed 只通过 claim 发给手机,并纳入设备签名上下文。网页只得到当前数字、服务端时间和本步期限。
|
|
1098
|
-
- 倒计时使用服务端时间锚点和单调时钟;本机系统时钟错误不直接导致倒计时漂移。网络延迟仍有影响,跨步时可能短暂显示“正在更新数字”,稳定后再核对。
|
|
1099
|
-
- 旧数字一过本步期限就隐藏,即使网络请求仍未返回。整个请求过期、拒绝或失败会隐藏数字和二维码。
|
|
1100
|
-
- 组件按请求检查成功结果、用途、双重认证策略和有效期。取消、卸载和切换请求后,旧响应不能触发成功回调。
|
|
1101
|
-
- 每个组件实例对同一请求只发一次成功事件;刷新或重新挂载不替代网站持久化幂等。
|
|
1102
|
-
- 包不接收网站业务授权内容,不提供 apiKey 配置,不执行支付/授权等业务操作。
|
|
1103
|
-
- 二维码在浏览器内生成,忽略后台 qrSvg,不使用 innerHTML;不上传给第三方图片服务。应用 CSP 需允许 `img-src 'self' data:`。
|
|
1104
|
-
|
|
1105
|
-
### 样式与语言
|
|
1106
|
-
|
|
1107
|
-
CSS 单独导入,所有类使用 `authlink-` 前缀。弹窗挂载到 `document.body`,可在 `:root` 或 `.authlink-dialog` 覆盖 `--authlink-foreground`、`--authlink-background`、`--authlink-accent`、`--authlink-muted`。面板语言见下表,默认简体中文。
|
|
1108
|
-
|
|
1109
|
-
React / Vue 均支持 `className`;Vue 还支持字符串、数组和对象形式的 `class`,与 `className` 合并。“重新发起”按钮使用父组件最新传入的 `onRefresh` 回调。
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
| Code | Language |
|
|
1113
|
-
| --- | --- |
|
|
1114
|
-
| `en` | English |
|
|
1115
|
-
| `zh-CN` | 简体中文 (default) |
|
|
1116
|
-
| `zh-TW` | 繁體中文 |
|
|
1117
|
-
| `ja` | 日本語 |
|
|
1118
|
-
| `bn` | বাংলা |
|
|
1119
|
-
| `es` | Español |
|
|
1120
|
-
| `fil` | Filipino |
|
|
1121
|
-
| `hi` | हिन्दी |
|
|
1122
|
-
| `id` | Bahasa Indonesia |
|
|
1123
|
-
| `ms` | Bahasa Melayu |
|
|
1124
|
-
| `pt` | Português |
|
|
1125
|
-
| `th` | ไทย |
|
|
1126
|
-
| `vi` | Tiếng Việt |
|
|
1127
|
-
|
|
1128
|
-
可以从根入口导入 `supportedLocales` 获取支持列表。文档默认英文,另有中文与日文版本。
|
|
1129
|
-
|
|
1130
|
-
### 兼容性
|
|
1131
|
-
|
|
1132
|
-
React 18.2+ / 19、Vue 3.3+、TypeScript、ESM。React 和 Vue 是可选 peer,只安装所用框架。构建工具环境需 Node 22.13+。
|
|
1133
|
-
|
|
1134
|
-
### 许可证
|
|
1135
|
-
|
|
1136
|
-
MIT。完整许可文本见 `LICENSE`,打包包含的第三方库归属说明见 `THIRD_PARTY_NOTICES.md`。
|
|
1137
|
-
|
|
1138
|
-
### Go Vue CMS: zh-CN
|
|
1139
|
-
|
|
1140
|
-
本指南按实际 Go CMS + Vue 3 的接入顺序整理。下面使用可替换的示例域名,不依赖某台电脑的配置、测试账号、密码或租户密钥。网站负责登录、数据库和业务接口;Web SDK 负责展示和观察请求,Go SDK 负责后台调用与回执校验。
|
|
1141
|
-
|
|
1142
|
-
#### 1. 区分三个来源并安装 SDK
|
|
1143
|
-
|
|
1144
|
-
| 角色 | 示例来源 | 配置位置 |
|
|
1145
|
-
| --- | --- | --- |
|
|
1146
|
-
| Vue | `https://cms.example.com` | `apiOrigin` 指向下方的 Go 后台 |
|
|
1147
|
-
| Go CMS API | `https://cms-api.example.com` | `baseUrl=https://cms-api.example.com/api/authlink` |
|
|
1148
|
-
| AuthLink API | `https://auth.example.com` | Go SDK 的 `Origin`;浏览器的 `authLinkOrigin` |
|
|
1149
|
-
|
|
1150
|
-
登记租户的站点 origin 是 Vue 网站来源;Go SDK 的 `Origin` 和凭据导出 JSON 中的 API origin 是 AuthLink API 来源。浏览器的 `baseUrl` 指向 Go 业务后台,不能指向 AuthLink 租户 API。公开运行配置只包含这三个 origin;`TenantID`、`APIKey` 保留在 Go 私密配置或环境变量中。
|
|
1151
|
-
|
|
1152
|
-
```sh
|
|
1153
|
-
npm install @auth-link/web@latest vue
|
|
1154
|
-
# In your Go backend module, after unpacking the supplied Go SDK:
|
|
1155
|
-
go mod edit -replace=authlink.local/sdk=./third_party/authlink-go
|
|
1156
|
-
go mod edit -require=authlink.local/sdk@v0.0.0
|
|
1157
|
-
go mod tidy
|
|
1158
|
-
```
|
|
1159
|
-
|
|
1160
|
-
Go SDK 当前随源码或官网的 Go SDK 源码包交付,保留 `third_party/authlink-go/go.mod`,导入 `authlink "authlink.local/sdk"`;这不是公共 `go get` 地址,也不是 npm 包内置的后台。用可信后台配置调用 `authlink.New(authlink.Config{Origin: authOrigin, TenantID: tenantID, APIKey: apiKey})`。
|
|
1161
|
-
|
|
1162
|
-
#### 2. 先完成网站登录,再读取会话
|
|
1163
|
-
|
|
1164
|
-
本站现有登录接口(参考 CMS 为 `POST /api/login`)验证用户后签发 HttpOnly、Secure 的 session Cookie。Vue 用 `credentials:'include'` 调用 Go 的 `GET /api/session`。已登录时返回 `authenticated:true` 与 `csrf`;未登录时先回到网站登录流程。
|
|
1165
|
-
|
|
1166
|
-
`csrf` 是本 CMS 的响应字段名;SDK 配置项仍叫 `csrfToken`,写成 `csrfToken: () => session.value?.csrf ?? ''` 即可。SDK 不要求你的后台一定返回名为 `csrfToken` 的字段。令牌由 Go 为当前 session 生成并保存,浏览器只读取,不能用租户密钥或手机核对码代替。创建和结果查询均需验证 `X-CSRF-Token` 与同一 session 的值一致。
|
|
1167
|
-
|
|
1168
|
-
Vue 与 API 使用同一 HTTPS 主域名下的两个子域名时,是跨 origin、同 site。参考 CMS 的 host-only Cookie 不设置 `Domain`,使用 `SameSite=Strict; Secure; HttpOnly; Path=/`;请求目标仍为 API 域名。真正跨 site 时还需按浏览器 Cookie 策略配置,不能只依靠 CORS。
|
|
1169
|
-
|
|
1170
|
-
#### 3. Go 配置 CORS 与业务路由
|
|
1171
|
-
|
|
1172
|
-
```http
|
|
1173
|
-
Access-Control-Allow-Origin: https://cms.example.com
|
|
1174
|
-
Access-Control-Allow-Credentials: true
|
|
1175
|
-
Access-Control-Allow-Methods: GET, POST, PUT, OPTIONS
|
|
1176
|
-
Access-Control-Allow-Headers: Content-Type, X-CSRF-Token, Idempotency-Key
|
|
1177
|
-
Vary: Origin
|
|
1178
|
-
Cache-Control: no-store
|
|
1179
|
-
```
|
|
1180
|
-
|
|
1181
|
-
只为精确允许的 Vue origin 返回这些 CORS 头;允许来源的正常和错误响应都要携带。`OPTIONS` 先验证来源、方法和请求头,成功返回 204,不要求登录或 CSRF;随后实际请求才验证登录、来源、CSRF 和归属。不能用 `*` 或无条件反射来源。上面包含 CMS 草稿编辑所用的 PUT;只接入 SDK 的站点可按实际路由收窄。
|
|
1182
|
-
|
|
1183
|
-
| 网站自己的路由 | 请求 | 返回 |
|
|
1184
|
-
| --- | --- | --- |
|
|
1185
|
-
| `GET /api/session` | — | `{authenticated:true, csrf:"…", subject:"…", username:"…"}`;未登录: `{authenticated:false}` |
|
|
1186
|
-
| `POST /api/authlink/requests` | `{purpose:"bind"}` 或 `{purpose:"confirm",postId,version}`; `Idempotency-Key` | 公开 `Ticket` |
|
|
1187
|
-
| `POST /api/authlink/requests/{id}/result` | `{}`;当前 session + CSRF | 待处理状态/displayCode 或已验证的 `confirmed` 回执 |
|
|
1188
|
-
| `POST /api/posts/{id}/publish` | `{requestId,version}`; 当前 session + CSRF | `{ok:true}` 校验已保存的确认记录和业务意图后返回 |
|
|
1189
|
-
|
|
1190
|
-
上述路由由 Go CMS 实现,不是安装 Web SDK 后自动出现的接口。`postId` / `version` / `/publish` 是本例业务约定,不是 AuthLink 平台字段。
|
|
1191
|
-
|
|
1192
|
-
#### 4. 创建前落库,消费后再返回 confirmed
|
|
1193
|
-
|
|
1194
|
-
```text
|
|
1195
|
-
Vue → Go /api/session → current session + csrf
|
|
1196
|
-
Vue → Go /api/authlink/requests → Go SDK CreateRequest → public ticket
|
|
1197
|
-
Vue dialog → Go /api/authlink/requests/{id}/result → Status + Consume
|
|
1198
|
-
Go commits verified receipt → Vue confirmed event
|
|
1199
|
-
Vue → Go /api/posts/{id}/publish → validate saved intent/receipt → publish
|
|
1200
|
-
```
|
|
1201
|
-
|
|
1202
|
-
Go 从可信 session 取稳定用户 ID,不能接受浏览器自报的 `externalUserId`。创建顺序:
|
|
1203
|
-
|
|
1204
|
-
1. 校验 purpose、创建幂等键,以及文章归属和当前版本(若有)。
|
|
1205
|
-
2. 生成独立随机 state 和消费幂等键;把原创建键、CreateInput、当前用户/session 和业务意图落库,再向 AuthLink 发请求。网络结果不明时从数据库恢复同一记录、输入和键。
|
|
1206
|
-
3. 调用 `CreateRequest`,保存返回的 requestId、原始 expiresAt 与 ticket,去掉可选 qrSvg 后返回公开 ticket。对同一 session + 创建键加唯一约束,键复用到其他 purpose / 文章 / 版本时返回冲突。
|
|
1207
|
-
4. 结果接口按 requestId + 当前 session 加锁读取记录;已保存回执直接复用。用 `Status` 查询,只有 approved / consumed 才以保存的 `Expected` 和消费键调用 `Consume`。SDK 校验失败不能返回成功。
|
|
1208
|
-
5. 在事务里保存回执、用户/session 确认记录和审计,requestId / verificationId 建唯一约束,提交成功后才返回 `confirmed`。待处理状态保留原 displayCode,不跨数字期限缓存。
|
|
1209
|
-
|
|
1210
|
-
下面是 SDK 调用层代码,**不是包含登录、数据库和路由的完整 HTTP 服务**;注释中的落库与事务必须由你的 Go 后台实现。初始化客户端、构造 Input、所有权检查及请求记录的读写按上述步骤完成。
|
|
1211
|
-
|
|
1212
|
-
```go
|
|
1213
|
-
package cmsauth
|
|
1214
|
-
|
|
1215
|
-
import (
|
|
1216
|
-
"context"
|
|
1217
|
-
authlink "authlink.local/sdk"
|
|
1218
|
-
)
|
|
1219
|
-
|
|
1220
|
-
// Load this record from your database for the authenticated user/session.
|
|
1221
|
-
// Save Input (including State), CreateKey and ConsumeKey BEFORE calling Create.
|
|
1222
|
-
// Intent (post ID/version), ownership and unique constraints belong in your DB.
|
|
1223
|
-
type StoredRequest struct {
|
|
1224
|
-
Input authlink.CreateInput
|
|
1225
|
-
CreateKey string
|
|
1226
|
-
ConsumeKey string
|
|
1227
|
-
Ticket authlink.Ticket
|
|
1228
|
-
}
|
|
1229
|
-
|
|
1230
|
-
// On network retries, load the SAME record; do not regenerate State or keys.
|
|
1231
|
-
func Create(ctx context.Context, sdk *authlink.Client, saved StoredRequest) (authlink.Ticket, error) {
|
|
1232
|
-
return sdk.CreateRequest(ctx, saved.Input, saved.CreateKey)
|
|
1233
|
-
}
|
|
1234
|
-
|
|
1235
|
-
type PublicConfirmation struct {
|
|
1236
|
-
Status string `json:"status"`
|
|
1237
|
-
RequestID string `json:"requestId"`
|
|
1238
|
-
VerificationID string `json:"verificationId"`
|
|
1239
|
-
Purpose string `json:"purpose"`
|
|
1240
|
-
ExpiresAt string `json:"expiresAt"`
|
|
1241
|
-
AuthenticationPolicy string `json:"authenticationPolicy"`
|
|
1242
|
-
}
|
|
1243
|
-
|
|
1244
|
-
// Caller checks session/CSRF/ownership, locks the saved record and first returns
|
|
1245
|
-
// any already-saved receipt. For a new confirmed result, caller must persist
|
|
1246
|
-
// the receipt and confirmation in a DB transaction BEFORE returning HTTP 200.
|
|
1247
|
-
func Poll(ctx context.Context, sdk *authlink.Client, saved StoredRequest) (any, error) {
|
|
1248
|
-
status, err := sdk.Status(ctx, saved.Ticket.RequestID)
|
|
1249
|
-
if err != nil { return nil, err }
|
|
1250
|
-
if status.Status != "approved" && status.Status != "consumed" { return status, nil }
|
|
1251
|
-
result, err := sdk.Consume(ctx, authlink.Expected{
|
|
1252
|
-
RequestID: saved.Ticket.RequestID,
|
|
1253
|
-
ExternalUserID: saved.Input.ExternalUserID,
|
|
1254
|
-
Purpose: saved.Input.Purpose,
|
|
1255
|
-
State: saved.Input.State,
|
|
1256
|
-
ExpiresAt: saved.Ticket.ExpiresAt,
|
|
1257
|
-
}, saved.ConsumeKey)
|
|
1258
|
-
if err != nil { return nil, err }
|
|
1259
|
-
return PublicConfirmation{
|
|
1260
|
-
Status: "confirmed", RequestID: result.RequestID,
|
|
1261
|
-
VerificationID: result.VerificationID, Purpose: result.Purpose,
|
|
1262
|
-
ExpiresAt: saved.Ticket.ExpiresAt, AuthenticationPolicy: result.AuthenticationPolicy,
|
|
1263
|
-
}, nil
|
|
1264
|
-
}
|
|
1265
|
-
```
|
|
1266
|
-
|
|
1267
|
-
#### 5. Vue 组件:稳定 ticket、按业务绑定创建、清理与重试
|
|
1268
|
-
|
|
1269
|
-
完整文件也包含在包内 `examples/vue-cms.vue`。网站登录后挂载它,传入可信 `apiOrigin`、`authLinkOrigin`,可选传入后台加载的草稿 `post={id,version}`。父层用当前登录 session 的标识做组件 key,退出、重新登录或切换账号时先卸载旧组件并清空会话、旧 ticket、幂等键和待发布操作,再挂载新组件。不要只改用户名后继续使用旧 ticket。
|
|
643
|
+
`examples/vue-client.vue` を AuthLinkPanel.vue としてコピーできます。ログイン後に現在の session.csrf を渡し、別 origin なら backendOrigin も渡します。ログアウト・アカウント/session 変更では旧コンポーネントをアンマウントして再作成します。token 更新時は csrf prop を更新します。
|
|
1270
644
|
|
|
1271
645
|
```vue
|
|
1272
646
|
<script setup lang="ts">
|
|
1273
|
-
import { markRaw,
|
|
1274
|
-
import { createAuthLinkClient, requestKey
|
|
647
|
+
import { markRaw, onUnmounted, ref, shallowRef } from 'vue';
|
|
648
|
+
import { createAuthLinkClient, requestKey } from '@auth-link/web';
|
|
1275
649
|
import type { Confirmation, Mode, Ticket } from '@auth-link/web';
|
|
1276
650
|
import { AuthLinkBind, AuthLinkConfirm } from '@auth-link/web/vue';
|
|
1277
651
|
import '@auth-link/web/styles.css';
|
|
1278
652
|
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
// Supply trusted deployment configuration. Mount after site login and remount
|
|
1282
|
-
// with :key="session.subject" after login, logout or account/session changes.
|
|
1283
|
-
const props = defineProps<{ apiOrigin: string; authLinkOrigin: string; post?: Post }>();
|
|
1284
|
-
const session = ref<{ authenticated: boolean; csrf?: string } | null>(null);
|
|
653
|
+
// csrf comes from your website's current authenticated session.
|
|
654
|
+
const props = defineProps<{ csrf: string; backendOrigin?: string }>();
|
|
1285
655
|
const ticket = shallowRef<Ticket | null>(null);
|
|
1286
|
-
const publication = shallowRef<Post | null>(null);
|
|
1287
|
-
const retryPublish = shallowRef<Publication | null>(null);
|
|
1288
656
|
const busy = ref(false), message = ref('');
|
|
1289
|
-
let creation: { signature: string; key: string } | null = null;
|
|
1290
657
|
const lifetime = new AbortController();
|
|
658
|
+
let creationKey: string | null = null;
|
|
659
|
+
let creationMode: Mode | null = null;
|
|
1291
660
|
const client = markRaw(createAuthLinkClient({
|
|
1292
|
-
baseUrl: `${props.
|
|
1293
|
-
allowedBackendOrigins: [props.
|
|
1294
|
-
credentials: 'include',
|
|
1295
|
-
csrfToken: () =>
|
|
1296
|
-
authLinkOrigin:
|
|
661
|
+
baseUrl: props.backendOrigin ? `${props.backendOrigin}/api/authlink` : '/api/authlink',
|
|
662
|
+
allowedBackendOrigins: props.backendOrigin ? [props.backendOrigin] : [],
|
|
663
|
+
credentials: props.backendOrigin ? 'include' : 'same-origin',
|
|
664
|
+
csrfToken: () => props.csrf,
|
|
665
|
+
authLinkOrigin: 'https://api.getauthlink.com',
|
|
1297
666
|
}));
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
credentials: 'include', cache: 'no-store', redirect: 'error',
|
|
1303
|
-
signal: lifetime.signal,
|
|
1304
|
-
headers: body === undefined ? {} : {
|
|
1305
|
-
'Content-Type': 'application/json', 'X-CSRF-Token': session.value?.csrf ?? '', ...headers,
|
|
1306
|
-
},
|
|
1307
|
-
body: body === undefined ? undefined : JSON.stringify(body),
|
|
1308
|
-
});
|
|
1309
|
-
const value = await response.json();
|
|
1310
|
-
if (response.status === 401) {
|
|
1311
|
-
session.value = null; closeTicket(); retryPublish.value = null; creation = null;
|
|
1312
|
-
}
|
|
1313
|
-
if (!response.ok) throw new Error(value.code ?? 'REQUEST_FAILED');
|
|
1314
|
-
return value;
|
|
667
|
+
function finish(text: string) { ticket.value = null; message.value = text; }
|
|
668
|
+
function confirmed(result: Confirmation) {
|
|
669
|
+
finish(`${result.purpose}: ${result.requestId}`);
|
|
670
|
+
// Reload your website's account/status API if the page needs fresh state.
|
|
1315
671
|
}
|
|
1316
|
-
function closeTicket() { ticket.value = null; publication.value = null; }
|
|
1317
672
|
function showError(error: Error) { message.value = error.message; }
|
|
1318
|
-
function
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
if (busy.value || ticket.value || !session.value?.authenticated) return;
|
|
1322
|
-
busy.value = true; message.value = ''; retryPublish.value = null;
|
|
1323
|
-
const intent = post ? { id: post.id, version: post.version } : null;
|
|
1324
|
-
const signature = JSON.stringify({ purpose, postId: intent?.id, version: intent?.version });
|
|
673
|
+
async function start(mode: Mode) {
|
|
674
|
+
if (busy.value || ticket.value) return;
|
|
675
|
+
busy.value = true; message.value = '';
|
|
1325
676
|
try {
|
|
1326
|
-
if (
|
|
1327
|
-
|
|
1328
|
-
const value =
|
|
1329
|
-
? validateTicket(await api('/api/authlink/requests', {
|
|
1330
|
-
purpose, postId: intent.id, version: intent.version,
|
|
1331
|
-
}, { 'Idempotency-Key': creation.key }), purpose, props.authLinkOrigin)
|
|
1332
|
-
: await client.create(purpose, { idempotencyKey: creation.key, signal: lifetime.signal });
|
|
677
|
+
if (creationMode !== mode) { creationKey = null; creationMode = mode; }
|
|
678
|
+
creationKey ??= requestKey();
|
|
679
|
+
const value = await client.create(mode, { idempotencyKey: creationKey, signal: lifetime.signal });
|
|
1333
680
|
if (lifetime.signal.aborted) return;
|
|
1334
|
-
|
|
681
|
+
ticket.value = value; creationKey = null;
|
|
1335
682
|
} catch (error) {
|
|
1336
683
|
if (!lifetime.signal.aborted) showError(error as Error);
|
|
1337
|
-
//
|
|
684
|
+
// Keep the same key when retrying an uncertain creation of the same mode.
|
|
1338
685
|
} finally { busy.value = false; }
|
|
1339
686
|
}
|
|
1340
|
-
|
|
1341
|
-
const intent = retryPublish.value;
|
|
1342
|
-
if (!intent || busy.value) return;
|
|
1343
|
-
busy.value = true;
|
|
1344
|
-
try {
|
|
1345
|
-
await api(`/api/posts/${intent.id}/publish`, { version: intent.version, requestId: intent.requestId });
|
|
1346
|
-
retryPublish.value = null; message.value = 'Published. Reload your posts from the backend.';
|
|
1347
|
-
} catch (error) { if (!lifetime.signal.aborted) showError(error as Error); }
|
|
1348
|
-
finally { busy.value = false; }
|
|
1349
|
-
}
|
|
1350
|
-
async function confirmed(result: Confirmation) {
|
|
1351
|
-
const intent = publication.value;
|
|
1352
|
-
closeTicket();
|
|
1353
|
-
if (intent) { retryPublish.value = { ...intent, requestId: result.requestId }; await publish(); }
|
|
1354
|
-
else message.value = 'Verified. Reload account status from the backend.';
|
|
1355
|
-
}
|
|
1356
|
-
onMounted(async () => {
|
|
1357
|
-
try {
|
|
1358
|
-
const value = await api('/api/session');
|
|
1359
|
-
if (value.authenticated !== true || typeof value.csrf !== 'string' || !value.csrf) {
|
|
1360
|
-
throw new Error('LOGIN_REQUIRED');
|
|
1361
|
-
}
|
|
1362
|
-
session.value = value;
|
|
1363
|
-
} catch (error) { if (!lifetime.signal.aborted) showError(error as Error); }
|
|
1364
|
-
});
|
|
1365
|
-
onUnmounted(() => { lifetime.abort(); closeTicket(); creation = null; retryPublish.value = null; });
|
|
687
|
+
onUnmounted(() => { lifetime.abort(); ticket.value = null; creationKey = null; });
|
|
1366
688
|
</script>
|
|
1367
689
|
|
|
1368
690
|
<template>
|
|
1369
|
-
<button :disabled="busy || !!ticket
|
|
1370
|
-
<button :disabled="busy || !!ticket
|
|
1371
|
-
<button v-if="post" :disabled="busy || !!ticket || !session?.authenticated" @click="start('confirm', post)">Confirm publication</button>
|
|
1372
|
-
<button v-if="retryPublish" :disabled="busy || !!ticket" @click="publish">Retry the same publication</button>
|
|
691
|
+
<button :disabled="busy || !!ticket" @click="start('bind')">Bind phone</button>
|
|
692
|
+
<button :disabled="busy || !!ticket" @click="start('confirm')">Confirm</button>
|
|
1373
693
|
<AuthLinkBind v-if="ticket?.purpose === 'bind'" :ticket="ticket" :client="client" variant="B"
|
|
1374
694
|
@confirmed="confirmed" @close="finish('Closed')" @rejected="finish('Rejected')"
|
|
1375
|
-
@expired="finish('Expired; start
|
|
695
|
+
@expired="finish('Expired; start again')" @error="showError" />
|
|
1376
696
|
<AuthLinkConfirm v-else-if="ticket?.purpose === 'confirm'" :ticket="ticket" :client="client" variant="B"
|
|
1377
697
|
@confirmed="confirmed" @close="finish('Closed')" @rejected="finish('Rejected')"
|
|
1378
|
-
@expired="finish('Expired; start
|
|
698
|
+
@expired="finish('Expired; start again')" @error="showError" />
|
|
1379
699
|
<p role="status">{{ message }}</p>
|
|
1380
700
|
</template>
|
|
1381
701
|
```
|
|
1382
702
|
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
同一次创建失败保留同一个键;用途或文章版本改变才换新键。`shallowRef` 保存 ticket,`markRaw` 保存客户端。组件挂载只打开和观察已有请求;不在 mounted/watch 中创建请求。关闭、拒绝和过期清空 ticket;网络错误保留弹窗,允许重试原请求。确认成功先清空 ticket,再调用独立业务接口;失败保留同一个 requestId / 文章 / 版本用于重试,先从后台确认真实状态后再刷新页面。上例省略网站自己的登录表单、文章列表和错误码中文映射。
|
|
1386
|
-
|
|
1387
|
-
#### 6. 发布接口必须再做后台检查
|
|
1388
|
-
|
|
1389
|
-
`confirmed` 回调只触发界面更新与下一次 API 请求,不是放行凭据。Go 的发布事务必须验证当前 session 对应的已落库回执,purpose=confirm、意图=publish、同一 postId、同一 version、文章归属和草稿状态,以及首次应用时尚未过期。成功时更新文章并标记请求 applied;重试已应用的同一操作返回原成功,不重复发布或写审计。请求不能用于另一文章或新版本,修改内容后须重新确认。前端不把文章内容、action 或 transactionHash 塞入 AuthLink 消费回执。
|
|
1390
|
-
|
|
1391
|
-
#### 验收顺序
|
|
1392
|
-
|
|
1393
|
-
登录 → 读取 session → 查询后台真实账号状态(参考 CMS 通过 overview 调用 Go SDK `AccountStatus`)→ 未绑定时 bind → 手机扫码批准 → 后台消费并刷新账号状态 → 保存草稿 → 按 postId/version confirm → 手机批准 → 结果消费落库 → 独立发布接口 → 重复发布/错误版本/拒绝/过期验证。真实设备验收仍由用户在手机上完成;本地编译和模拟 HTTP 测试不能代替这一步。
|
|
1394
|
-
|
|
1395
|
-
---
|
|
1396
|
-
|
|
1397
|
-
## 日本語
|
|
1398
|
-
|
|
1399
|
-
**AuthLink のスマートフォン登録とアプリでの確認**を Web サイトに組み込む React / Vue コンポーネントです。登録用 QR コード、同期する 6 桁の照合コード、カウントダウン、確認結果を表示します。特定のサイト基盤には依存しません。
|
|
1400
|
-
|
|
1401
|
-
### Go + Vue の接続手順
|
|
1402
|
-
|
|
1403
|
-
Go + Vue の実際の接続手順は [ログイン、端末登録、確認後の公開](#go-vue-cms-ja)、完全なコンポーネントは `examples/vue-cms.vue` を参照してください。
|
|
1404
|
-
|
|
1405
|
-
サイトのログイン → Go `/api/session` の authenticated/csrf → `csrfToken: () => session.csrf` で別 origin のクライアント設定 → bind/confirm 作成 → Go で消費・検証・DB コミット → ダイアログを閉じる → 独立した業務 API で保存済み確認結果を再検証して公開、という順序です。baseUrl は Go、authLinkOrigin は AuthLink を指し、テナントキーは Go に保持します。記事/版付き要求は独自 fetch と validateTicket を使い、SDK create は purpose のみ送ります。
|
|
1406
|
-
|
|
1407
|
-
### 主な機能
|
|
1408
|
-
|
|
1409
|
-
- ブラウザー内で生成するスマートフォン登録用 QR コード。
|
|
1410
|
-
- アプリと同期し、30 秒ごとに更新される照合コード。
|
|
1411
|
-
- 承認・拒否・期限切れ・エラーのイベント。
|
|
1412
|
-
- React コンポーネントと hook、Vue コンポーネントと composable、フレームワーク非依存の API。
|
|
1413
|
-
- **簡体字中国語を既定とする 13 言語**。コンポーネントごとに指定可能。
|
|
1414
|
-
- TypeScript、独立した CSS、任意の React / Vue peer dependency。
|
|
1415
|
-
|
|
1416
|
-
本パッケージはブラウザー用の接続ライブラリです。サイトのバックエンド、AuthLink サービス、モバイルアプリと併用します。AuthLink はアプリ内の承認と元の端末のハードウェア証明を検証します。システムのパスキー、指紋認証、画面ロックは要求しません。
|
|
1417
|
-
|
|
1418
|
-
### インストール
|
|
1419
|
-
|
|
1420
|
-
```sh
|
|
1421
|
-
npm install @auth-link/web
|
|
1422
|
-
```
|
|
1423
|
-
|
|
1424
|
-
対応環境:React 18.2+ / 19、Vue 3.3+、TypeScript、ESM。利用するフレームワークだけをインストールしてください。ツール環境には Node 22.13+ が必要です。
|
|
1425
|
-
|
|
1426
|
-
### 接続の流れ
|
|
1427
|
-
|
|
1428
|
-
1. サイトのバックエンドに AuthLink のテナント資格情報を設定します。
|
|
1429
|
-
2. 下記の業務バックエンド(同一オリジンまたは明示的に許可した別オリジン)の作成・結果取得 API を実装します。
|
|
1430
|
-
3. ユーザー操作でリクエストを作成し、取得した ticket をコンポーネントに渡します。
|
|
1431
|
-
4. バックエンドが結果を検証・消費した後に確認完了を返します。
|
|
1432
|
-
|
|
1433
|
-
ブラウザーからこのライブラリを呼び出せますが、テナント API キーと結果の消費処理は必ずサーバー側に置きます。標準クライアントは既定の同一オリジンと、明示的に許可した別オリジンに対応します。独自の通信方式では `AuthLinkClient` を実装できますが、資格情報と検証をブラウザーに移さないでください。
|
|
1434
|
-
|
|
1435
|
-
### クライアント作成(既定の同一オリジン Cookie モード)
|
|
1436
|
-
|
|
1437
|
-
`readCsrfTokenFromYourCms` は SDK のエクスポートではなく、接続先サイトで実装する関数です。現在のログインセッションに対してバックエンドが発行した CSRF token を読み取ります。CMS は必須ではありません。ブラウザーで値を生成したり、AuthLink の API キーを返したりしないでください。
|
|
1438
|
-
|
|
1439
|
-
既存フレームワークの CSRF 機構を優先してください。独自に実装する場合は、サーバーで暗号学的乱数(Node の `randomBytes(32).toString('base64url')` など)を生成し、セッションに保存して HTML 属性をエスケープしたうえでページに埋め込みます。下記の `{{ csrfToken }}` はサーバーテンプレートの変数です。このページを公開キャッシュしないでください。
|
|
1440
|
-
|
|
1441
|
-
```html
|
|
1442
|
-
<meta name="csrf-token" content="{{ csrfToken }}">
|
|
1443
|
-
```
|
|
1444
|
-
|
|
1445
|
-
SDK は各リクエストで token を読み取り、`X-CSRF-Token` と同一オリジンのセッション cookie を送信します。作成 API と結果 API の両方で、バックエンドが同じセッションの token と照合し、欠落・不一致や不正なリクエスト元を拒否する必要があります。ログインやセッション変更後は最新 token を取得してください。SPA ではサイト独自の同一オリジン初期化 API から取得する方法も使えます。別のヘッダー名を使うフレームワークでは`csrfHeaderName` または独自 `AuthLinkClient` で適合します。
|
|
1446
|
-
|
|
1447
|
-
`authlink-client.ts` を作成します:
|
|
1448
|
-
|
|
1449
|
-
```ts
|
|
1450
|
-
// authlink-client.ts
|
|
1451
|
-
import { createAuthLinkClient } from '@auth-link/web';
|
|
1452
|
-
import '@auth-link/web/styles.css';
|
|
1453
|
-
|
|
1454
|
-
// The application server renders this meta element using the current session's
|
|
1455
|
-
// CSRF token. This function only reads that token; it does not generate one.
|
|
1456
|
-
export function readCsrfTokenFromYourCms(): string {
|
|
1457
|
-
const token = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content;
|
|
1458
|
-
if (!token) throw new Error('Missing CSRF token; load the authenticated application page first.');
|
|
1459
|
-
return token;
|
|
1460
|
-
}
|
|
1461
|
-
|
|
1462
|
-
export const client = createAuthLinkClient({
|
|
1463
|
-
baseUrl: '/api/authlink',
|
|
1464
|
-
csrfToken: readCsrfTokenFromYourCms,
|
|
1465
|
-
// Replace this example with the configured AuthLink service origin.
|
|
1466
|
-
authLinkOrigin: 'https://auth.example.com',
|
|
1467
|
-
});
|
|
1468
|
-
```
|
|
1469
|
-
|
|
1470
|
-
`https://auth.example.com` は実際の AuthLink オリジンに置き換えてください。リクエストは下記の React 例のようにユーザー操作で作成します。
|
|
1471
|
-
|
|
1472
|
-
### フロントエンドとバックエンドの別オリジン対応(0.0.2 以降)
|
|
1473
|
-
|
|
1474
|
-
既定は同一オリジンの `/api/authlink` です。別オリジンの業務 API に直接接続するには完全な `baseUrl` と `allowedBackendOrigins` を指定します。例:フロントエンド `https://www.example.com`、バックエンド `https://api.example.com`。サブドメインやポートが異なる場合も別オリジンです。許可リストにはパス、ワイルドカード、ユーザー名・パスワードを含めず、信頼する固定設定を使います。HTTPS が必須で、開発用 HTTP loopback(localhost / 127.0.0.1 / [::1])のみ例外です。
|
|
1475
|
-
|
|
1476
|
-
SDK は認証情報を読む前に接続先を検証し、リダイレクトを拒否します。`authLinkOrigin` は QR 内の AuthLink サービスオリジンの検証用で、業務 API の許可リストとは別です。
|
|
1477
|
-
|
|
1478
|
-
#### 別オリジンの Cookie / session
|
|
1479
|
-
|
|
1480
|
-
汎用例の JSON 名は csrfToken ですが、Go CMS の実際の応答は `{ authenticated: true, csrf: '…' }` です。session.csrf を SDK の csrfToken コールバックに渡せばよく、バックエンドのフィールド名を変更する必要はありません。
|
|
1481
|
-
|
|
1482
|
-
下記の `GET /api/session` はサイトで実装する例示 API で、SDK が提供する API ではありません。ログイン session の `{ "csrfToken": "現在のtoken" }` を返し、`Cache-Control: no-store` と CORS を設定します。既存サイトの API パスに適合してください。
|
|
1483
|
-
|
|
1484
|
-
```ts
|
|
1485
|
-
import { createAuthLinkClient } from '@auth-link/web';
|
|
1486
|
-
|
|
1487
|
-
/** /api/session is an example endpoint implemented by your application backend. */
|
|
1488
|
-
export async function createSessionBackendClient(backendOrigin: string) {
|
|
1489
|
-
const response = await fetch(`${backendOrigin}/api/session`, {
|
|
1490
|
-
credentials: 'include', cache: 'no-store', redirect: 'error',
|
|
1491
|
-
});
|
|
1492
|
-
if (!response.ok) throw new Error('Session initialization failed');
|
|
1493
|
-
const session: unknown = await response.json();
|
|
1494
|
-
if (!session || typeof session !== 'object' || !('csrfToken' in session) ||
|
|
1495
|
-
typeof session.csrfToken !== 'string' || !session.csrfToken) {
|
|
1496
|
-
throw new Error('The session endpoint must return { csrfToken: string }');
|
|
1497
|
-
}
|
|
1498
|
-
let currentCsrfToken = session.csrfToken;
|
|
1499
|
-
return {
|
|
1500
|
-
client: createAuthLinkClient({
|
|
1501
|
-
baseUrl: `${backendOrigin}/api/authlink`,
|
|
1502
|
-
allowedBackendOrigins: [backendOrigin],
|
|
1503
|
-
credentials: 'include',
|
|
1504
|
-
csrfToken: () => currentCsrfToken,
|
|
1505
|
-
}),
|
|
1506
|
-
// Call after your application's session/token refresh; recreate on account changes.
|
|
1507
|
-
updateCsrfToken(value: string) { currentCsrfToken = value; },
|
|
1508
|
-
};
|
|
1509
|
-
}
|
|
1510
|
-
```
|
|
1511
|
-
|
|
1512
|
-
ログイン後に `await createSessionBackendClient('https://api.example.com')` を呼び、返された `client` を既存コンポーネントに渡します。CSRF token 更新時は `updateCsrfToken(newToken)` を呼びます。ログアウトやアカウント切替時は古いダイアログを閉じ、状態を再初期化します。別オリジン Cookie には `credentials: 'include'` が必要ですが、SameSite、Secure、domain/path、ブラウザーの第三者 Cookie 制限も適用されます。別オリジンと cross-site は同じではありません。cross-site Cookie には通常 `SameSite=None; Secure` が必要で、ブラウザーによっては引き続き拒否されます。
|
|
1513
|
-
|
|
1514
|
-
#### Bearer token / JWT
|
|
1515
|
-
|
|
1516
|
-
サイトの既存ログイン状態を渡してください。ログイン・更新処理で `auth.accessToken` を更新すると、SDK は作成・ポーリングのたびに最新値を読み取ります。
|
|
1517
|
-
|
|
1518
|
-
```ts
|
|
1519
|
-
import { createAuthLinkClient } from '@auth-link/web';
|
|
1520
|
-
|
|
1521
|
-
/** auth is your website's existing login state, updated by its login/refresh flow. */
|
|
1522
|
-
export function createBearerBackendClient(backendOrigin: string, auth: { accessToken: string }) {
|
|
1523
|
-
return createAuthLinkClient({
|
|
1524
|
-
baseUrl: `${backendOrigin}/api/authlink`,
|
|
1525
|
-
allowedBackendOrigins: [backendOrigin],
|
|
1526
|
-
credentials: 'omit',
|
|
1527
|
-
accessToken: () => auth.accessToken,
|
|
1528
|
-
});
|
|
1529
|
-
}
|
|
1530
|
-
```
|
|
1531
|
-
|
|
1532
|
-
これはサイト利用者のログイン token であり、AuthLink のテナント API キーではありません。SDK が `Authorization: Bearer ...` を付加します。`accessToken` 指定時は既定で `credentials: 'omit'` となり、Cookie にフォールバックせず Bearer のみで認証するバックエンドでは CSRF を省略できます。`include` / `same-origin` を明示して Cookie を併用する場合は CSRF の提供・検証も必要です。
|
|
1533
|
-
|
|
1534
|
-
#### バックエンドの CORS
|
|
1535
|
-
|
|
1536
|
-
`OPTIONS` プリフライトを処理し、実リクエスト・エラー応答・session 初期化 API にも必要な CORS ヘッダーを返します。フロントエンドが `https://www.example.com` の例:
|
|
1537
|
-
|
|
1538
|
-
```http
|
|
1539
|
-
Access-Control-Allow-Origin: https://www.example.com
|
|
1540
|
-
Access-Control-Allow-Credentials: true
|
|
1541
|
-
Access-Control-Allow-Methods: GET, POST, OPTIONS
|
|
1542
|
-
Access-Control-Allow-Headers: Content-Type, X-CSRF-Token, Idempotency-Key, Authorization
|
|
1543
|
-
Vary: Origin
|
|
1544
|
-
```
|
|
1545
|
-
|
|
1546
|
-
Cookie を使う場合 `Access-Control-Allow-Origin: *` は使用できません。バックエンドの許可リストと一致する Origin のみ返し、任意の値を反射しないでください。プリフライトには通常ログイン認証情報がないため、まず Origin・メソッド・ヘッダーを検証し、実リクエストで本人確認・CSRF(Cookie モード)・所有者を検証します。独自ヘッダーや CSRF ヘッダー名は `Access-Control-Allow-Headers` に追加します。SDK はサーバーの CORS 設定を代行できず、`no-cors` で結果を読めません。[MDN CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS) を参照してください。
|
|
1547
|
-
|
|
1548
|
-
#### クライアント設定
|
|
1549
|
-
|
|
1550
|
-
| 設定 | 説明 |
|
|
1551
|
-
| --- | --- |
|
|
1552
|
-
| `baseUrl` | 業務 API の接頭辞、既定 `/api/authlink`;別オリジン時は完全 URL |
|
|
1553
|
-
| `allowedBackendOrigins` | 追加許可する正確な業務 API オリジンの配列 |
|
|
1554
|
-
| `credentials` | 既定 `same-origin`、`accessToken` 指定時 `omit`;別オリジン Cookie は `include` |
|
|
1555
|
-
| `csrfToken` | リクエストごとの同期/非同期取得関数;Cookie モードは必須 |
|
|
1556
|
-
| `csrfHeaderName` | 既定 `X-CSRF-Token`、フレームワークに合わせて変更可能 |
|
|
1557
|
-
| `accessToken` | サイトのログイン token 取得関数;`Bearer ` 接頭辞は付けない |
|
|
1558
|
-
| `headers` | 追加 `HeadersInit`、またはそれを返す同期/非同期関数 |
|
|
1559
|
-
| `authLinkOrigin` | QR 内の信頼するサービスオリジン。業務 API オリジンとは別 |
|
|
1560
|
-
| `fetch` | 任意の fetch 実装;接続先検証は維持 |
|
|
1561
|
-
|
|
1562
|
-
追加ヘッダーは SDK 管理の `Accept`、`Content-Type`、`Authorization`、`Idempotency-Key`、設定済み CSRF ヘッダーを上書きできません(`RESERVED_HEADER`)。`accessToken` と `csrfHeaderName` を使用してください。設定型 `ClientOptions` はルートエントリーからインポートできます。
|
|
1563
|
-
|
|
1564
|
-
未許可オリジンは `BACKEND_ORIGIN_NOT_ALLOWED`(旧 `CMS_ORIGIN_REQUIRED` に代わる)、不正な許可設定/URL は `INVALID_BACKEND_ORIGIN` / `INVALID_BASE_URL`、token 不足は `ACCESS_TOKEN_REQUIRED` / `CSRF_REQUIRED` となります。非同期の認証情報取得にも取消しと 10 秒タイムアウトが適用されます。CORS の詳細な失敗理由は開発者ツールで確認してください。SDK では通常 `NETWORK_ERROR` になります。
|
|
703
|
+
ticket は shallowRef、client は markRaw で保持します。同じ用途の作成失敗ではキーを保持し、成功後に消去します。mounted/watch で要求を作りません。確認・拒否・期限切れ・閉じる操作で ticket を消去します。通信エラーでは表示を維持して同じ要求を再試行します。必要なアカウント状態は自サイトの API から再取得します。
|
|
1565
704
|
|
|
1566
|
-
###
|
|
1567
|
-
|
|
1568
|
-
```ts
|
|
1569
|
-
import { openAuthLinkBind, openAuthLinkConfirm, requestKey } from '@auth-link/web';
|
|
1570
|
-
import { client } from './authlink-client';
|
|
1571
|
-
import '@auth-link/web/styles.css';
|
|
705
|
+
### 4. React
|
|
1572
706
|
|
|
1573
|
-
|
|
1574
|
-
const modal = openAuthLinkConfirm({
|
|
1575
|
-
ticket, client, locale: 'ja',
|
|
1576
|
-
onConfirmed: result => console.log(result.requestId),
|
|
1577
|
-
onClose: () => console.log('Closed'),
|
|
1578
|
-
});
|
|
1579
|
-
// 登録: openAuthLinkBind({ ticket: bindingTicket, client })
|
|
1580
|
-
// modal.close(); modal.setLocale('en');
|
|
1581
|
-
```
|
|
1582
|
-
|
|
1583
|
-
React / Vue コンポーネントもマウント時にダイアログを開きます。React の onClose、Vue の @close で ticket を消去してアンマウントしてください。SSR はダイアログも通信も生成しません。スマートフォンの画面幅、スクロール、閉じるボタン、Escape に対応し、ネイティブ dialog.showModal() が必要です。body 直下に表示するため、テーマ変数は :root または .authlink-dialog に設定します。
|
|
1584
|
-
|
|
1585
|
-
閉じてもサーバーのリクエストを拒否・取消しません。元の有効期限は維持されます。承認・拒否時は自動で閉じ、onConfirmed または onRejected を通知します。ユーザー操作の onClose は通知しません。関数で開いた場合はページ破棄時に modal.destroy() を呼んで通信と UI を解除してください。destroy() は onClose を通知しません。modal.setLocale() はリクエストを作り直さずに言語を切り替えます。
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
### React
|
|
1589
|
-
|
|
1590
|
-
`showBound(requestId)` はサイトの表示更新用関数です。メッセージを更新し、ticket を消去してダイアログをアンマウントします。下記には拒否・期限切れ・閉じる処理も含まれます。`<AuthLinkPanel mode="bind" />` または `<AuthLinkPanel mode="confirm" />` で使います。
|
|
707
|
+
上記で初期化した安定した client と mode=bind/confirm を渡します。ログアウトやアカウント/session 変更ではアンマウントします。
|
|
1591
708
|
|
|
1592
709
|
```tsx
|
|
1593
710
|
"use client";
|
|
1594
|
-
import { useRef, useState } from 'react';
|
|
711
|
+
import { useEffect, useRef, useState } from 'react';
|
|
1595
712
|
import { requestKey } from '@auth-link/web';
|
|
1596
|
-
import type { Mode, Ticket } from '@auth-link/web';
|
|
713
|
+
import type { AuthLinkClient, Mode, Ticket } from '@auth-link/web';
|
|
1597
714
|
import { AuthLinkBind, AuthLinkConfirm } from '@auth-link/web/react';
|
|
1598
|
-
|
|
715
|
+
// Import '@auth-link/web/styles.css' once in your application entry.
|
|
1599
716
|
|
|
1600
|
-
export function AuthLinkPanel({ mode }: { mode: Mode }) {
|
|
717
|
+
export function AuthLinkPanel({ client, mode }: { client: AuthLinkClient; mode: Mode }) {
|
|
1601
718
|
const [ticket, setTicket] = useState<Ticket | null>(null);
|
|
1602
719
|
const [busy, setBusy] = useState(false);
|
|
1603
720
|
const [message, setMessage] = useState('');
|
|
1604
|
-
const
|
|
1605
|
-
const
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
}
|
|
1614
|
-
function showConfirmed(requestId: string) {
|
|
1615
|
-
finish(`Confirmed (request ${requestId})`);
|
|
1616
|
-
}
|
|
1617
|
-
const start = async () => {
|
|
1618
|
-
if (creating.current || ticket) return;
|
|
1619
|
-
creating.current = true;
|
|
1620
|
-
setBusy(true);
|
|
1621
|
-
setMessage('');
|
|
721
|
+
const creation = useRef<{ mode: Mode; key: string } | null>(null);
|
|
722
|
+
const inFlight = useRef(false);
|
|
723
|
+
const controller = useRef<AbortController | null>(null);
|
|
724
|
+
useEffect(() => () => { controller.current?.abort(); }, []);
|
|
725
|
+
function finish(text: string) { setTicket(null); setMessage(text); }
|
|
726
|
+
async function start() {
|
|
727
|
+
if (inFlight.current || ticket) return;
|
|
728
|
+
inFlight.current = true; setBusy(true); setMessage('');
|
|
729
|
+
const attempt = new AbortController(); controller.current = attempt;
|
|
1622
730
|
try {
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
731
|
+
if (creation.current?.mode !== mode) creation.current = { mode, key: requestKey() };
|
|
732
|
+
const value = await client.create(mode, { idempotencyKey: creation.current.key, signal: attempt.signal });
|
|
733
|
+
if (attempt.signal.aborted) return;
|
|
734
|
+
setTicket(value); creation.current = null;
|
|
735
|
+
} catch (error) { if (!attempt.signal.aborted) setMessage((error as Error).message); }
|
|
736
|
+
finally {
|
|
737
|
+
inFlight.current = false;
|
|
738
|
+
if (!attempt.signal.aborted) setBusy(false);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
1629
741
|
const Component = mode === 'bind' ? AuthLinkBind : AuthLinkConfirm;
|
|
1630
742
|
return <>
|
|
1631
|
-
{!ticket && <button
|
|
1632
|
-
{ticket && <Component ticket={ticket} client={client}
|
|
1633
|
-
onConfirmed={result => result.purpose
|
|
1634
|
-
onClose={() => finish('Closed')}
|
|
1635
|
-
|
|
1636
|
-
onExpired={() => finish('Request expired; start again')}
|
|
743
|
+
{!ticket && <button disabled={busy} onClick={() => void start()}>Start verification</button>}
|
|
744
|
+
{ticket && <Component ticket={ticket} client={client} variant="B"
|
|
745
|
+
onConfirmed={result => finish(`${result.purpose}: ${result.requestId}`)}
|
|
746
|
+
onClose={() => finish('Closed')} onRejected={() => finish('Rejected')}
|
|
747
|
+
onExpired={() => finish('Expired; start again')}
|
|
1637
748
|
onError={error => setMessage(error.message)} />}
|
|
1638
749
|
<p role="status">{message}</p>
|
|
1639
750
|
</>;
|
|
1640
751
|
}
|
|
1641
752
|
```
|
|
1642
753
|
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
`locale` を省略すると簡体字中国語です。日本語は `locale="ja"`、簡体字中国語は `locale="zh-CN"` を指定します。client と ticket はレンダー間で安定したオブジェクトにしてください。React エントリーは `"use client"` を保持し、SSR ではリクエストを作成しません。
|
|
1647
|
-
|
|
1648
|
-
### Vue
|
|
1649
|
-
|
|
1650
|
-
```vue
|
|
1651
|
-
<script setup lang="ts">
|
|
1652
|
-
import { AuthLinkBind, AuthLinkConfirm } from '@auth-link/web/vue';
|
|
1653
|
-
import type { Ticket, Confirmation } from '@auth-link/web';
|
|
1654
|
-
import { client } from './authlink-client';
|
|
1655
|
-
|
|
1656
|
-
defineProps<{ ticket: Ticket }>();
|
|
1657
|
-
function confirmed(result: Confirmation) {
|
|
1658
|
-
console.log('Confirmed request:', result.requestId);
|
|
1659
|
-
}
|
|
1660
|
-
</script>
|
|
1661
|
-
|
|
1662
|
-
<template>
|
|
1663
|
-
<AuthLinkBind v-if="ticket.purpose === 'bind'" :ticket="ticket" :client="client"
|
|
1664
|
-
@confirmed="confirmed" />
|
|
1665
|
-
<AuthLinkConfirm v-else :ticket="ticket" :client="client" locale="ja"
|
|
1666
|
-
@confirmed="confirmed" />
|
|
1667
|
-
</template>
|
|
1668
|
-
```
|
|
1669
|
-
|
|
1670
|
-
`:locale="selectedLanguage"` で表示言語を切り替えられます。言語切り替えによって新しい認証リクエストは作成されません。
|
|
1671
|
-
|
|
1672
|
-
### レイアウトの選択
|
|
1673
|
-
|
|
1674
|
-
初期化時の `variant` で選択します。省略時は **A** です。
|
|
1675
|
-
|
|
1676
|
-
| 値 | デスクトップ | モバイル |
|
|
1677
|
-
| --- | --- | --- |
|
|
1678
|
-
| `A`(既定) | ミニマルなカードと細いコード更新バー | 中央のカード |
|
|
1679
|
-
| `B` | QR と照合コードの二列表示 | 縦並び |
|
|
1680
|
-
| `C` | コンパクトなカードとキャンセル操作 | セーフエリア対応のボトムシート |
|
|
754
|
+
### 5. 通常の JavaScript / 直接表示
|
|
1681
755
|
|
|
1682
756
|
```ts
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
// modal.setVariant('C');
|
|
1686
|
-
```
|
|
1687
|
-
|
|
1688
|
-
```tsx
|
|
1689
|
-
<AuthLinkBind ticket={ticket} client={client} variant="C" />
|
|
1690
|
-
```
|
|
757
|
+
import { openAuthLinkConfirm, requestKey } from '@auth-link/web';
|
|
758
|
+
import '@auth-link/web/styles.css';
|
|
1691
759
|
|
|
1692
|
-
|
|
1693
|
-
|
|
760
|
+
// Inside a click handler; client is the stable instance created above.
|
|
761
|
+
const ticket = await client.create('confirm', { idempotencyKey: requestKey() });
|
|
762
|
+
const modal = openAuthLinkConfirm({
|
|
763
|
+
ticket, client, variant: 'B', locale: 'zh-CN',
|
|
764
|
+
onConfirmed: result => console.log(result.requestId),
|
|
765
|
+
onRejected: result => console.log(result.code),
|
|
766
|
+
onExpired: () => console.log('Expired'),
|
|
767
|
+
onError: error => console.log(error.message),
|
|
768
|
+
onClose: () => console.log('Closed'),
|
|
769
|
+
});
|
|
770
|
+
// Route/component cleanup: modal.destroy();
|
|
771
|
+
// Optional: modal.close(); modal.retry(); modal.setLocale('ja'); modal.setVariant('C');
|
|
1694
772
|
```
|
|
1695
773
|
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
React / Vue では表示中にも変更でき、リクエストやポーリングは再作成されません。未対応の値は A に戻ります。B / C のキャンセル操作は閉じるアイコンと同じく表示の監視を停止し、サーバーのリクエストは拒否しません。確認成功時は自動で閉じます。閉じる操作のタッチ領域は 44px で、キーボードのフォーカス表示も維持します。
|
|
774
|
+
登録時は client.create('bind', ...) と openAuthLinkBind(...) を使います。確認・拒否は自動で閉じてから通知し、onClose は発火しません。閉じる操作は監視を停止します。画面を離れる際は destroy を呼びます。同じ要求の成功はインスタンス内で一度だけ通知します。イベントは表示更新用で、保護操作は自サイトの業務 API を呼びます。
|
|
1699
775
|
|
|
1700
|
-
###
|
|
776
|
+
### 6. フロントエンドが使う API の形式
|
|
1701
777
|
|
|
1702
|
-
|
|
|
778
|
+
| 自サイトのルート | 送信 | 受信 |
|
|
1703
779
|
| --- | --- | --- |
|
|
1704
|
-
|
|
|
1705
|
-
|
|
|
1706
|
-
| `locale` | `Locale` | 既定は `zh-CN` |
|
|
1707
|
-
| `variant` | `DialogVariant` | `A`(既定)、`B`、`C` |
|
|
1708
|
-
| `showQr` | `boolean` | 確認画面にも QR を表示。登録画面では常に表示 |
|
|
1709
|
-
| `intervalMs` | `number` | 250~10000 ミリ秒。既定は 1500 ミリ秒 |
|
|
1710
|
-
| `onRefresh` | `() => void` | 期限切れ後、新規作成して ticket を更新する処理 |
|
|
1711
|
-
|
|
1712
|
-
| React コールバック / Vue イベント | 引数 | 意味 |
|
|
1713
|
-
| --- | --- | --- |
|
|
1714
|
-
| `onConfirmed` / `confirmed` | `Confirmation` | バックエンドによる検証と消費が完了 |
|
|
1715
|
-
| `onRejected` / `rejected` | `Rejection` | 拒否時はダイアログを自動で閉じる |
|
|
1716
|
-
| `onExpired` / `expired` | なし | 有効期限切れ |
|
|
1717
|
-
| `onError` / `error` | `Error` | 通信・応答検証・コールバックのエラー |
|
|
1718
|
-
|
|
1719
|
-
拒否時は `{ status: 'rejected', code: 'REJECTED', requestId, purpose }` を返します。`REJECTED` は SDK の業務ステータスで、HTTP ステータスや承認証明ではありません。拒否・再試行画面は表示せず、`onClose` も通知しません。メッセージや新規作成はサイト側で処理してください。React / Vue では `onRejected` / `@rejected` で ticket を解除してコンポーネントを破棄します。ヘッドレス hook の `snapshot.status` は `rejected` のままです。
|
|
1720
|
-
|
|
1721
|
-
独自 UI には React の `useAuthLink(options)`、Vue の `useAuthLink(() => options)` を利用できます。返される値は `snapshot` と `retry` です。Vue の snapshot は shallow ref です。retry は既存リクエストを再開し、onRefresh はサイト側のコードで新規作成します。
|
|
780
|
+
| POST /api/authlink/requests | purpose=bind/confirm、Idempotency-Key | Ticket |
|
|
781
|
+
| POST /api/authlink/requests/{requestId}/result | 空オブジェクト、現在のログイン情報 | Result(自動監視) |
|
|
1722
782
|
|
|
1723
|
-
|
|
783
|
+
Cookie モードでは両方に現在の CSRF ヘッダーを送ります。Ticket は requestId、purpose、qr、pairingCode、expiresAt、confirm 時は displayCode も必要です。Ticket/Result/Confirmation 型を import し、QR や数字を自作しません。confirmed のみ成功イベントを発火します。new/pending/approved/consumed は待機、rejected/expired は対応イベントです。
|
|
1724
784
|
|
|
1725
|
-
|
|
785
|
+
create() は purpose のみ送ります。業務固有項目が必要なら独自 fetch で作成し、validateTicket で検証して同じコンポーネントへ渡します。post/session/backendOrigin は既存のフロントエンド状態で、同じ入力の再試行では同じキーを保持します。
|
|
1726
786
|
|
|
1727
787
|
```ts
|
|
1728
|
-
import
|
|
1729
|
-
|
|
1730
|
-
//
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
"status": "confirmed",
|
|
1742
|
-
"requestId": "<request-id>",
|
|
1743
|
-
"verificationId": "<verification-id>",
|
|
1744
|
-
"purpose": "confirm",
|
|
1745
|
-
"authenticationPolicy": "device-presence.v1",
|
|
1746
|
-
"expiresAt": "<same-expiry-as-ticket>"
|
|
1747
|
-
}
|
|
788
|
+
import { requestKey, validateTicket } from '@auth-link/web';
|
|
789
|
+
|
|
790
|
+
// Keep this key across retries of the SAME operation and payload.
|
|
791
|
+
const idempotencyKey = requestKey();
|
|
792
|
+
const response = await fetch(`${backendOrigin}/api/authlink/requests`, {
|
|
793
|
+
method: 'POST', credentials: 'include', cache: 'no-store', redirect: 'error',
|
|
794
|
+
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': session.csrf,
|
|
795
|
+
'Idempotency-Key': idempotencyKey },
|
|
796
|
+
body: JSON.stringify({ purpose: 'confirm', postId: post.id, version: post.version }),
|
|
797
|
+
});
|
|
798
|
+
if (!response.ok) throw new Error('Creation failed');
|
|
799
|
+
const ticket = validateTicket(await response.json(), 'confirm', 'https://api.getauthlink.com');
|
|
800
|
+
// Pass ticket and the same client to AuthLinkConfirm.
|
|
1748
801
|
```
|
|
1749
802
|
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
結果 API は未完了状態と最新の表示コードを返します。`new` / `pending` / `approved` / `consumed` だけでは成功イベントは発火しません。`confirmed` を返す前に、バックエンドは保存した state と消費用冪等キーで AuthLink の結果を消費し、完全な受領情報を検証して正しいセッションに記録します。requestId / verificationId による永続的な冪等処理が必要です。
|
|
1753
|
-
|
|
1754
|
-
### 対応言語
|
|
1755
|
-
|
|
1756
|
-
| Code | Language |
|
|
1757
|
-
| --- | --- |
|
|
1758
|
-
| `en` | English |
|
|
1759
|
-
| `zh-CN` | 简体中文 (default) |
|
|
1760
|
-
| `zh-TW` | 繁體中文 |
|
|
1761
|
-
| `ja` | 日本語 |
|
|
1762
|
-
| `bn` | বাংলা |
|
|
1763
|
-
| `es` | Español |
|
|
1764
|
-
| `fil` | Filipino |
|
|
1765
|
-
| `hi` | हिन्दी |
|
|
1766
|
-
| `id` | Bahasa Indonesia |
|
|
1767
|
-
| `ms` | Bahasa Melayu |
|
|
1768
|
-
| `pt` | Português |
|
|
1769
|
-
| `th` | ไทย |
|
|
1770
|
-
| `vi` | Tiếng Việt |
|
|
1771
|
-
|
|
1772
|
-
未指定・未対応の言語コードは簡体字中国語にフォールバックします。ルート要素には `lang` が設定されます。ルートエントリーの `supportedLocales` で一覧を取得できます。言語変更は表示文言だけに作用し、数字やリクエストは変更しません。ドキュメントは英語・中国語・日本語で提供します。
|
|
1773
|
-
|
|
1774
|
-
### スタイルとエントリー
|
|
1775
|
-
|
|
1776
|
-
`@auth-link/web/styles.css` を明示的に読み込みます。ダイアログは `document.body` に追加されるため、`:root` または `.authlink-dialog` で `--authlink-foreground`、`--authlink-background`、`--authlink-accent`、`--authlink-muted` を上書きします。クラス名の接頭辞は `authlink-` です。
|
|
1777
|
-
|
|
1778
|
-
React / Vue はダイアログの `className` に対応します。Vue は文字列・配列・オブジェクト形式の `class` も受け取り、`className` と結合します。再作成ボタンは親が渡した最新の `onRefresh` を呼び出します。
|
|
803
|
+
### 7. 設定・イベント・表示
|
|
1779
804
|
|
|
1780
|
-
|
|
|
1781
|
-
| --- | --- |
|
|
1782
|
-
| `@auth-link/web` | クライアント、冪等キー、対応言語、型、基本 API |
|
|
1783
|
-
| `@auth-link/web/react` | React コンポーネントと hook |
|
|
1784
|
-
| `@auth-link/web/vue` | Vue コンポーネントと composable |
|
|
1785
|
-
| `@auth-link/web/styles.css` | スタイル |
|
|
1786
|
-
|
|
1787
|
-
### セキュリティと有効期限
|
|
1788
|
-
|
|
1789
|
-
- 数字はアプリとの目視照合用です。数字だけで認証や承認はできません。
|
|
1790
|
-
- ブラウザーに生成 seed、端末秘密鍵、テナント秘密情報を渡しません。
|
|
1791
|
-
- リクエストは最大 120 秒。コード更新で期限は延長されません。サービスとアプリはモバイルプロトコル 7 に対応する必要があります。
|
|
1792
|
-
- サーバー時刻と単調増加時計で残り時間を計算し、通信が止まっても期限切れの数字は隠します。更新境界で短い待ち時間が生じる場合があります。
|
|
1793
|
-
- アンマウントや ticket 変更で監視を中止します。インスタンス内のイベント重複防止とは別に、バックエンドで永続的な冪等処理が必要です。
|
|
1794
|
-
- フロントエンドのイベントは UI 更新用です。保護 API はバックエンドの信頼できる記録で判断します。
|
|
1795
|
-
- QR はローカル生成し、バックエンドの SVG を挿入しません。CSP は `img-src 'self' data:` を許可してください。
|
|
1796
|
-
|
|
1797
|
-
### ライセンス
|
|
1798
|
-
|
|
1799
|
-
MIT。ライセンス全文は `LICENSE`、同梱ライブラリの帰属表示は `THIRD_PARTY_NOTICES.md` を参照してください。
|
|
1800
|
-
|
|
1801
|
-
### Go Vue CMS: ja
|
|
1802
|
-
|
|
1803
|
-
実際の Go CMS + Vue 3 の接続順序を示します。例のドメインは置き換え用で、ローカル設定・テスト用パスワード・テナント資格情報は含めません。ログイン、DB、業務 API はサイト側で実装し、Web SDK は表示と監視、Go SDK は AuthLink 呼び出しと応答検証を担当します。
|
|
1804
|
-
|
|
1805
|
-
#### 1. 3 つの origin と SDK
|
|
1806
|
-
|
|
1807
|
-
| 役割 | 例 | 設定 |
|
|
805
|
+
| prop | 型 | 用途 |
|
|
1808
806
|
| --- | --- | --- |
|
|
1809
|
-
|
|
|
1810
|
-
|
|
|
1811
|
-
|
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
go mod edit -require=authlink.local/sdk@v0.0.0
|
|
1820
|
-
go mod tidy
|
|
1821
|
-
```
|
|
1822
|
-
|
|
1823
|
-
Go SDK はソースまたは公式サイトの Go SDK アーカイブとして取得します。公開 Go module や npm 同梱サーバーではありません。SDK 自身の go.mod を保持し、`authlink "authlink.local/sdk"` を import します。信頼できる設定から `authlink.New(authlink.Config{Origin: authOrigin, TenantID: tenantID, APIKey: apiKey})` で初期化します。
|
|
1824
|
-
|
|
1825
|
-
#### 2. サイトのログインと session
|
|
1826
|
-
|
|
1827
|
-
サイトのログイン API(参照 CMS は POST /api/login)が Secure・HttpOnly の session Cookie を発行します。Vue は credentials=include で GET /api/session を読み、authenticated=true と csrf を確認してから要求を作成します。未ログインならサイト自身のログインへ戻します。
|
|
1828
|
-
|
|
1829
|
-
この CMS の JSON フィールド名は **csrf**、SDK オプション名は **csrfToken** です。`csrfToken: () => session.value?.csrf ?? ''` で対応します。JSON の名前を SDK に合わせる必要はありません。Go が session ごとに生成・保存し、作成と結果照会の両方で X-CSRF-Token を検証します。テナントキーや照合数字を代用しません。
|
|
1830
|
-
|
|
1831
|
-
同じサイトの HTTPS サブドメイン同士は別 origin・同 site です。参照 API の Cookie は Domain を指定しない host-only、SameSite=Strict; Secure; HttpOnly; Path=/ です。別 site の場合は CORS に加えてブラウザーの Cookie 制限も考慮します。
|
|
1832
|
-
|
|
1833
|
-
#### 3. Go の CORS と業務ルート
|
|
1834
|
-
|
|
1835
|
-
```http
|
|
1836
|
-
Access-Control-Allow-Origin: https://cms.example.com
|
|
1837
|
-
Access-Control-Allow-Credentials: true
|
|
1838
|
-
Access-Control-Allow-Methods: GET, POST, PUT, OPTIONS
|
|
1839
|
-
Access-Control-Allow-Headers: Content-Type, X-CSRF-Token, Idempotency-Key
|
|
1840
|
-
Vary: Origin
|
|
1841
|
-
Cache-Control: no-store
|
|
1842
|
-
```
|
|
1843
|
-
|
|
1844
|
-
正確に許可した Vue origin にだけ返し、その origin のエラー応答にも付与します。OPTIONS は origin・method・headers を検証して 204 を返し、ログインや CSRF は要求しません。実リクエストでログイン、CSRF、所有者を検証します。任意 origin の反射や * は使いません。PUT は CMS の下書き編集用なので必要なルートに合わせて限定できます。
|
|
1845
|
-
|
|
1846
|
-
| サイト自身のルート | 入力 | 出力 |
|
|
807
|
+
| `ticket` | `Ticket` | Required; the current request |
|
|
808
|
+
| `client` | `AuthLinkClient` | Required; keep the instance stable |
|
|
809
|
+
| `locale` | `Locale` | Default `zh-CN` |
|
|
810
|
+
| `variant` | `'A' / 'B' / 'C'` | Default `A` |
|
|
811
|
+
| `showQr` | `boolean` | Optional QR for confirm; bind always shows QR |
|
|
812
|
+
| `intervalMs` | `number` | Default 1500; range 250–10000 ms |
|
|
813
|
+
| `onRefresh` | `() => void` | Optional user action after expiry; create a new ticket |
|
|
814
|
+
| `className` | `string` | Custom dialog class; Vue also accepts `class` |
|
|
815
|
+
|
|
816
|
+
| React コールバック / Vue イベント | 値 | 動作 |
|
|
1847
817
|
| --- | --- | --- |
|
|
1848
|
-
| `
|
|
1849
|
-
| `
|
|
1850
|
-
| `
|
|
1851
|
-
| `
|
|
818
|
+
| `onConfirmed` / `confirmed` | `Confirmation` | Success; dialog closes automatically |
|
|
819
|
+
| `onRejected` / `rejected` | `Rejection` | `{status:'rejected',code:'REJECTED',requestId,purpose}`; dialog closes |
|
|
820
|
+
| `onExpired` / `expired` | — | Request expired |
|
|
821
|
+
| `onClose` / `close` | — | User closes the dialog; clear ticket |
|
|
822
|
+
| `onError` / `error` | `Error` | Read `error.message`; retain the dialog for retry |
|
|
1852
823
|
|
|
1853
|
-
|
|
824
|
+
A は中央カード(既定)、B はデスクトップ分割・狭い画面で縦配置、C は小型・モバイルで下部シートです。locale/variant の変更では要求を作り直しません。ネイティブ dialog.showModal() 対応ブラウザーが必要です。
|
|
1854
825
|
|
|
1855
|
-
|
|
826
|
+
CSS を明示 import します。ダイアログは document.body 配下なので :root または .authlink-dialog で --authlink-foreground、--authlink-background、--authlink-accent、--authlink-muted を指定します。Vue class は文字列・配列・オブジェクトを className と併用できます。SSR はダイアログや要求を作りません。
|
|
1856
827
|
|
|
1857
|
-
|
|
1858
|
-
Vue → Go /api/session → current session + csrf
|
|
1859
|
-
Vue → Go /api/authlink/requests → Go SDK CreateRequest → public ticket
|
|
1860
|
-
Vue dialog → Go /api/authlink/requests/{id}/result → Status + Consume
|
|
1861
|
-
Go commits verified receipt → Vue confirmed event
|
|
1862
|
-
Vue → Go /api/posts/{id}/publish → validate saved intent/receipt → publish
|
|
1863
|
-
```
|
|
1864
|
-
|
|
1865
|
-
安定したユーザー ID は認証済み session から取得します。用途、作成キー、記事の所有者と版を検証し、state と消費キーを別々に生成します。上流通信の前に CreateInput、ブラウザーの作成キー、ユーザー/session、記事公開の意図を保存します。通信失敗時は同一レコード・入力・キーを再利用します。session + 作成キーを一意にし、異なる用途・記事・版への再利用を拒否します。
|
|
1866
|
-
|
|
1867
|
-
ticket、requestId、元の expiresAt を保存し、任意の qrSvg を除いて公開 ticket を返します。結果 API は現在の session が所有する記録をロックし、保存済み確認結果があれば再利用します。Status が approved/consumed のときだけ、DB の値から Expected を組み立て Consume を実行します。検証済み確認結果、session の確認記録、監査を requestId/verificationId の一意制約付きトランザクションで保存し、コミット後に confirmed を返します。待機中は displayCode を有効期限を越えてキャッシュしません。
|
|
1868
|
-
|
|
1869
|
-
以下は SDK 呼び出し層であり、完全な HTTP サーバーではありません。DB、トランザクション、所有者検証は説明とコメントに従ってサイト側で実装します。
|
|
1870
|
-
|
|
1871
|
-
```go
|
|
1872
|
-
package cmsauth
|
|
1873
|
-
|
|
1874
|
-
import (
|
|
1875
|
-
"context"
|
|
1876
|
-
authlink "authlink.local/sdk"
|
|
1877
|
-
)
|
|
828
|
+
言語:`zh-CN` (default), `zh-TW`, `en`, `ja`, `bn`, `es`, `fil`, `hi`, `id`, `ms`, `pt`, `th`, `vi`。未指定・未対応値は簡体字中国語へフォールバックします。supportedLocales で一覧を取得できます。
|
|
1878
829
|
|
|
1879
|
-
|
|
1880
|
-
// Save Input (including State), CreateKey and ConsumeKey BEFORE calling Create.
|
|
1881
|
-
// Intent (post ID/version), ownership and unique constraints belong in your DB.
|
|
1882
|
-
type StoredRequest struct {
|
|
1883
|
-
Input authlink.CreateInput
|
|
1884
|
-
CreateKey string
|
|
1885
|
-
ConsumeKey string
|
|
1886
|
-
Ticket authlink.Ticket
|
|
1887
|
-
}
|
|
1888
|
-
|
|
1889
|
-
// On network retries, load the SAME record; do not regenerate State or keys.
|
|
1890
|
-
func Create(ctx context.Context, sdk *authlink.Client, saved StoredRequest) (authlink.Ticket, error) {
|
|
1891
|
-
return sdk.CreateRequest(ctx, saved.Input, saved.CreateKey)
|
|
1892
|
-
}
|
|
830
|
+
### 8. 通信設定とエラー確認
|
|
1893
831
|
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
// the receipt and confirmation in a DB transaction BEFORE returning HTTP 200.
|
|
1906
|
-
func Poll(ctx context.Context, sdk *authlink.Client, saved StoredRequest) (any, error) {
|
|
1907
|
-
status, err := sdk.Status(ctx, saved.Ticket.RequestID)
|
|
1908
|
-
if err != nil { return nil, err }
|
|
1909
|
-
if status.Status != "approved" && status.Status != "consumed" { return status, nil }
|
|
1910
|
-
result, err := sdk.Consume(ctx, authlink.Expected{
|
|
1911
|
-
RequestID: saved.Ticket.RequestID,
|
|
1912
|
-
ExternalUserID: saved.Input.ExternalUserID,
|
|
1913
|
-
Purpose: saved.Input.Purpose,
|
|
1914
|
-
State: saved.Input.State,
|
|
1915
|
-
ExpiresAt: saved.Ticket.ExpiresAt,
|
|
1916
|
-
}, saved.ConsumeKey)
|
|
1917
|
-
if err != nil { return nil, err }
|
|
1918
|
-
return PublicConfirmation{
|
|
1919
|
-
Status: "confirmed", RequestID: result.RequestID,
|
|
1920
|
-
VerificationID: result.VerificationID, Purpose: result.Purpose,
|
|
1921
|
-
ExpiresAt: saved.Ticket.ExpiresAt, AuthenticationPolicy: result.AuthenticationPolicy,
|
|
1922
|
-
}, nil
|
|
1923
|
-
}
|
|
1924
|
-
```
|
|
1925
|
-
|
|
1926
|
-
#### 5. Vue のライフサイクルと業務データ付き要求
|
|
1927
|
-
|
|
1928
|
-
完全なファイルは `examples/vue-cms.vue` にも同梱します。サイトのログイン後、信頼できる apiOrigin/authLinkOrigin と任意の保存済み下書き `post={id,version}` を渡してマウントします。親は認証 session の識別子を key に使います。ログアウト・再ログイン・アカウント切り替えでは先にアンマウントし、古い ticket・作成キー・未完了の公開処理を消去してから新規マウントします。
|
|
1929
|
-
|
|
1930
|
-
```vue
|
|
1931
|
-
<script setup lang="ts">
|
|
1932
|
-
import { markRaw, onMounted, onUnmounted, ref, shallowRef } from 'vue';
|
|
1933
|
-
import { createAuthLinkClient, requestKey, validateTicket } from '@auth-link/web';
|
|
1934
|
-
import type { Confirmation, Mode, Ticket } from '@auth-link/web';
|
|
1935
|
-
import { AuthLinkBind, AuthLinkConfirm } from '@auth-link/web/vue';
|
|
1936
|
-
import '@auth-link/web/styles.css';
|
|
1937
|
-
|
|
1938
|
-
type Post = { id: string; version: number };
|
|
1939
|
-
type Publication = Post & { requestId: string };
|
|
1940
|
-
// Supply trusted deployment configuration. Mount after site login and remount
|
|
1941
|
-
// with :key="session.subject" after login, logout or account/session changes.
|
|
1942
|
-
const props = defineProps<{ apiOrigin: string; authLinkOrigin: string; post?: Post }>();
|
|
1943
|
-
const session = ref<{ authenticated: boolean; csrf?: string } | null>(null);
|
|
1944
|
-
const ticket = shallowRef<Ticket | null>(null);
|
|
1945
|
-
const publication = shallowRef<Post | null>(null);
|
|
1946
|
-
const retryPublish = shallowRef<Publication | null>(null);
|
|
1947
|
-
const busy = ref(false), message = ref('');
|
|
1948
|
-
let creation: { signature: string; key: string } | null = null;
|
|
1949
|
-
const lifetime = new AbortController();
|
|
1950
|
-
const client = markRaw(createAuthLinkClient({
|
|
1951
|
-
baseUrl: `${props.apiOrigin}/api/authlink`,
|
|
1952
|
-
allowedBackendOrigins: [props.apiOrigin],
|
|
1953
|
-
credentials: 'include',
|
|
1954
|
-
csrfToken: () => session.value?.csrf ?? '',
|
|
1955
|
-
authLinkOrigin: props.authLinkOrigin,
|
|
1956
|
-
}));
|
|
1957
|
-
|
|
1958
|
-
async function api(path: string, body?: unknown, headers: Record<string, string> = {}) {
|
|
1959
|
-
const response = await fetch(`${props.apiOrigin}${path}`, {
|
|
1960
|
-
method: body === undefined ? 'GET' : 'POST',
|
|
1961
|
-
credentials: 'include', cache: 'no-store', redirect: 'error',
|
|
1962
|
-
signal: lifetime.signal,
|
|
1963
|
-
headers: body === undefined ? {} : {
|
|
1964
|
-
'Content-Type': 'application/json', 'X-CSRF-Token': session.value?.csrf ?? '', ...headers,
|
|
1965
|
-
},
|
|
1966
|
-
body: body === undefined ? undefined : JSON.stringify(body),
|
|
1967
|
-
});
|
|
1968
|
-
const value = await response.json();
|
|
1969
|
-
if (response.status === 401) {
|
|
1970
|
-
session.value = null; closeTicket(); retryPublish.value = null; creation = null;
|
|
1971
|
-
}
|
|
1972
|
-
if (!response.ok) throw new Error(value.code ?? 'REQUEST_FAILED');
|
|
1973
|
-
return value;
|
|
1974
|
-
}
|
|
1975
|
-
function closeTicket() { ticket.value = null; publication.value = null; }
|
|
1976
|
-
function showError(error: Error) { message.value = error.message; }
|
|
1977
|
-
function finish(text: string) { closeTicket(); message.value = text; }
|
|
1978
|
-
|
|
1979
|
-
async function start(purpose: Mode, post?: Post) {
|
|
1980
|
-
if (busy.value || ticket.value || !session.value?.authenticated) return;
|
|
1981
|
-
busy.value = true; message.value = ''; retryPublish.value = null;
|
|
1982
|
-
const intent = post ? { id: post.id, version: post.version } : null;
|
|
1983
|
-
const signature = JSON.stringify({ purpose, postId: intent?.id, version: intent?.version });
|
|
1984
|
-
try {
|
|
1985
|
-
if (creation?.signature !== signature) creation = { signature, key: requestKey() };
|
|
1986
|
-
// SDK create() sends only {purpose}. Business fields use your own API.
|
|
1987
|
-
const value = intent
|
|
1988
|
-
? validateTicket(await api('/api/authlink/requests', {
|
|
1989
|
-
purpose, postId: intent.id, version: intent.version,
|
|
1990
|
-
}, { 'Idempotency-Key': creation.key }), purpose, props.authLinkOrigin)
|
|
1991
|
-
: await client.create(purpose, { idempotencyKey: creation.key, signal: lifetime.signal });
|
|
1992
|
-
if (lifetime.signal.aborted) return;
|
|
1993
|
-
publication.value = intent; ticket.value = value; creation = null;
|
|
1994
|
-
} catch (error) {
|
|
1995
|
-
if (!lifetime.signal.aborted) showError(error as Error);
|
|
1996
|
-
// Retain creation.key for a retry of the SAME purpose/post/version.
|
|
1997
|
-
} finally { busy.value = false; }
|
|
1998
|
-
}
|
|
1999
|
-
async function publish() {
|
|
2000
|
-
const intent = retryPublish.value;
|
|
2001
|
-
if (!intent || busy.value) return;
|
|
2002
|
-
busy.value = true;
|
|
2003
|
-
try {
|
|
2004
|
-
await api(`/api/posts/${intent.id}/publish`, { version: intent.version, requestId: intent.requestId });
|
|
2005
|
-
retryPublish.value = null; message.value = 'Published. Reload your posts from the backend.';
|
|
2006
|
-
} catch (error) { if (!lifetime.signal.aborted) showError(error as Error); }
|
|
2007
|
-
finally { busy.value = false; }
|
|
2008
|
-
}
|
|
2009
|
-
async function confirmed(result: Confirmation) {
|
|
2010
|
-
const intent = publication.value;
|
|
2011
|
-
closeTicket();
|
|
2012
|
-
if (intent) { retryPublish.value = { ...intent, requestId: result.requestId }; await publish(); }
|
|
2013
|
-
else message.value = 'Verified. Reload account status from the backend.';
|
|
2014
|
-
}
|
|
2015
|
-
onMounted(async () => {
|
|
2016
|
-
try {
|
|
2017
|
-
const value = await api('/api/session');
|
|
2018
|
-
if (value.authenticated !== true || typeof value.csrf !== 'string' || !value.csrf) {
|
|
2019
|
-
throw new Error('LOGIN_REQUIRED');
|
|
2020
|
-
}
|
|
2021
|
-
session.value = value;
|
|
2022
|
-
} catch (error) { if (!lifetime.signal.aborted) showError(error as Error); }
|
|
2023
|
-
});
|
|
2024
|
-
onUnmounted(() => { lifetime.abort(); closeTicket(); creation = null; retryPublish.value = null; });
|
|
2025
|
-
</script>
|
|
2026
|
-
|
|
2027
|
-
<template>
|
|
2028
|
-
<button :disabled="busy || !!ticket || !session?.authenticated" @click="start('bind')">Bind phone</button>
|
|
2029
|
-
<button :disabled="busy || !!ticket || !session?.authenticated" @click="start('confirm')">Confirm</button>
|
|
2030
|
-
<button v-if="post" :disabled="busy || !!ticket || !session?.authenticated" @click="start('confirm', post)">Confirm publication</button>
|
|
2031
|
-
<button v-if="retryPublish" :disabled="busy || !!ticket" @click="publish">Retry the same publication</button>
|
|
2032
|
-
<AuthLinkBind v-if="ticket?.purpose === 'bind'" :ticket="ticket" :client="client" variant="B"
|
|
2033
|
-
@confirmed="confirmed" @close="finish('Closed')" @rejected="finish('Rejected')"
|
|
2034
|
-
@expired="finish('Expired; start a new request')" @error="showError" />
|
|
2035
|
-
<AuthLinkConfirm v-else-if="ticket?.purpose === 'confirm'" :ticket="ticket" :client="client" variant="B"
|
|
2036
|
-
@confirmed="confirmed" @close="finish('Closed')" @rejected="finish('Rejected')"
|
|
2037
|
-
@expired="finish('Expired; start a new request')" @error="showError" />
|
|
2038
|
-
<p role="status">{{ message }}</p>
|
|
2039
|
-
</template>
|
|
2040
|
-
```
|
|
2041
|
-
|
|
2042
|
-
client.create が送るのは purpose のみです。postId/version が必要なら業務 API に fetch で送り、`validateTicket(response, 'confirm', authLinkOrigin)` で検証した後、監視には公式 client を使います。同じ作成の失敗ではキーを保持し、用途・記事・版が変わったときだけ新規キーにします。ticket は shallowRef、client は markRaw で保持します。
|
|
2043
|
-
|
|
2044
|
-
マウント時は既存要求を監視し、mounted/watch で新規作成しません。閉じる・拒否・期限切れでは ticket を消去し、通信エラーでは同じ要求を再試行できるよう表示を維持します。confirmed 後は表示を閉じ、独立した公開 API を呼びます。失敗時は同じ requestId/記事/版で再試行し、実状態をバックエンドから再取得します。サイト自身のログインフォーム・記事一覧・エラー翻訳は例から省略しています。
|
|
832
|
+
| 設定 | 用途 |
|
|
833
|
+
| --- | --- |
|
|
834
|
+
| `baseUrl` | Your application API prefix; default `/api/authlink` |
|
|
835
|
+
| `allowedBackendOrigins` | Exact extra application API origins; no paths/wildcards |
|
|
836
|
+
| `credentials` | Default `same-origin`; cross-origin cookies need `include` |
|
|
837
|
+
| `csrfToken` | Sync/async getter for the current website CSRF token |
|
|
838
|
+
| `csrfHeaderName` | Default `X-CSRF-Token` |
|
|
839
|
+
| `accessToken` | Sync/async website Bearer token getter, without `Bearer ` |
|
|
840
|
+
| `headers` | Extra headers or sync/async getter |
|
|
841
|
+
| `authLinkOrigin` | `https://api.getauthlink.com` for production AuthLink |
|
|
842
|
+
| `fetch` | Optional fetch implementation |
|
|
2045
843
|
|
|
2046
|
-
|
|
844
|
+
headers は Accept、Content-Type、Authorization、Idempotency-Key、設定済み CSRF ヘッダーを上書きできません。error.message を確認します。CSRF_REQUIRED は session token、ACCESS_TOKEN_REQUIRED はログイン状態、BACKEND_ORIGIN_NOT_ALLOWED は正確な許可 origin、NETWORK_ERROR は開発者ツールの Network・通信・CORS を確認します。タイムアウトは 10 秒、閉じる/アンマウントで監視を中止します。
|
|
2047
845
|
|
|
2048
|
-
|
|
846
|
+
期限切れの数字は隠れます。数字をフロントエンドで生成・キャッシュしません。QR はローカル生成で、CSP 使用時は data: 画像を許可します。イベントや localStorage、ブラウザーからの状態値は業務上の許可証ではありません。
|
|
2049
847
|
|
|
2050
|
-
|
|
848
|
+
### ライセンス
|
|
2051
849
|
|
|
2052
|
-
|
|
850
|
+
MIT。LICENSE と THIRD_PARTY_NOTICES.md を参照してください。
|