@auth-link/web 0.0.4 → 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.
@@ -0,0 +1,42 @@
1
+ "use client";
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { requestKey } from '@auth-link/web';
4
+ import type { AuthLinkClient, Mode, Ticket } from '@auth-link/web';
5
+ import { AuthLinkBind, AuthLinkConfirm } from '@auth-link/web/react';
6
+ // Import '@auth-link/web/styles.css' once in your application entry.
7
+
8
+ export function AuthLinkPanel({ client, mode }: { client: AuthLinkClient; mode: Mode }) {
9
+ const [ticket, setTicket] = useState<Ticket | null>(null);
10
+ const [busy, setBusy] = useState(false);
11
+ const [message, setMessage] = useState('');
12
+ const creation = useRef<{ mode: Mode; key: string } | null>(null);
13
+ const inFlight = useRef(false);
14
+ const controller = useRef<AbortController | null>(null);
15
+ useEffect(() => () => { controller.current?.abort(); }, []);
16
+ function finish(text: string) { setTicket(null); setMessage(text); }
17
+ async function start() {
18
+ if (inFlight.current || ticket) return;
19
+ inFlight.current = true; setBusy(true); setMessage('');
20
+ const attempt = new AbortController(); controller.current = attempt;
21
+ try {
22
+ if (creation.current?.mode !== mode) creation.current = { mode, key: requestKey() };
23
+ const value = await client.create(mode, { idempotencyKey: creation.current.key, signal: attempt.signal });
24
+ if (attempt.signal.aborted) return;
25
+ setTicket(value); creation.current = null;
26
+ } catch (error) { if (!attempt.signal.aborted) setMessage((error as Error).message); }
27
+ finally {
28
+ inFlight.current = false;
29
+ if (!attempt.signal.aborted) setBusy(false);
30
+ }
31
+ }
32
+ const Component = mode === 'bind' ? AuthLinkBind : AuthLinkConfirm;
33
+ return <>
34
+ {!ticket && <button disabled={busy} onClick={() => void start()}>Start verification</button>}
35
+ {ticket && <Component ticket={ticket} client={client} variant="B"
36
+ onConfirmed={result => finish(`${result.purpose}: ${result.requestId}`)}
37
+ onClose={() => finish('Closed')} onRejected={() => finish('Rejected')}
38
+ onExpired={() => finish('Expired; start again')}
39
+ onError={error => setMessage(error.message)} />}
40
+ <p role="status">{message}</p>
41
+ </>;
42
+ }
@@ -0,0 +1,55 @@
1
+ <script setup lang="ts">
2
+ import { markRaw, onUnmounted, ref, shallowRef } from 'vue';
3
+ import { createAuthLinkClient, requestKey } from '@auth-link/web';
4
+ import type { Confirmation, Mode, Ticket } from '@auth-link/web';
5
+ import { AuthLinkBind, AuthLinkConfirm } from '@auth-link/web/vue';
6
+ import '@auth-link/web/styles.css';
7
+
8
+ // csrf comes from your website's current authenticated session.
9
+ const props = defineProps<{ csrf: string; backendOrigin?: string }>();
10
+ const ticket = shallowRef<Ticket | null>(null);
11
+ const busy = ref(false), message = ref('');
12
+ const lifetime = new AbortController();
13
+ let creationKey: string | null = null;
14
+ let creationMode: Mode | null = null;
15
+ const client = markRaw(createAuthLinkClient({
16
+ baseUrl: props.backendOrigin ? `${props.backendOrigin}/api/authlink` : '/api/authlink',
17
+ allowedBackendOrigins: props.backendOrigin ? [props.backendOrigin] : [],
18
+ credentials: props.backendOrigin ? 'include' : 'same-origin',
19
+ csrfToken: () => props.csrf,
20
+ authLinkOrigin: 'https://api.getauthlink.com',
21
+ }));
22
+ function finish(text: string) { ticket.value = null; message.value = text; }
23
+ function confirmed(result: Confirmation) {
24
+ finish(`${result.purpose}: ${result.requestId}`);
25
+ // Reload your website's account/status API if the page needs fresh state.
26
+ }
27
+ function showError(error: Error) { message.value = error.message; }
28
+ async function start(mode: Mode) {
29
+ if (busy.value || ticket.value) return;
30
+ busy.value = true; message.value = '';
31
+ try {
32
+ if (creationMode !== mode) { creationKey = null; creationMode = mode; }
33
+ creationKey ??= requestKey();
34
+ const value = await client.create(mode, { idempotencyKey: creationKey, signal: lifetime.signal });
35
+ if (lifetime.signal.aborted) return;
36
+ ticket.value = value; creationKey = null;
37
+ } catch (error) {
38
+ if (!lifetime.signal.aborted) showError(error as Error);
39
+ // Keep the same key when retrying an uncertain creation of the same mode.
40
+ } finally { busy.value = false; }
41
+ }
42
+ onUnmounted(() => { lifetime.abort(); ticket.value = null; creationKey = null; });
43
+ </script>
44
+
45
+ <template>
46
+ <button :disabled="busy || !!ticket" @click="start('bind')">Bind phone</button>
47
+ <button :disabled="busy || !!ticket" @click="start('confirm')">Confirm</button>
48
+ <AuthLinkBind v-if="ticket?.purpose === 'bind'" :ticket="ticket" :client="client" variant="B"
49
+ @confirmed="confirmed" @close="finish('Closed')" @rejected="finish('Rejected')"
50
+ @expired="finish('Expired; start again')" @error="showError" />
51
+ <AuthLinkConfirm v-else-if="ticket?.purpose === 'confirm'" :ticket="ticket" :client="client" variant="B"
52
+ @confirmed="confirmed" @close="finish('Closed')" @rejected="finish('Rejected')"
53
+ @expired="finish('Expired; start again')" @error="showError" />
54
+ <p role="status">{{ message }}</p>
55
+ </template>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@auth-link/web",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "private": false,
5
5
  "description": "Three responsive AuthLink dialog layouts for phone binding and mobile confirmation. Framework-neutral API, React and Vue, 13 languages, Simplified Chinese by default.",
6
6
  "type": "module",
@@ -8,11 +8,14 @@
8
8
  "files": [
9
9
  "dist",
10
10
  "README.md",
11
- "docs",
11
+ "docs/README.en.md",
12
+ "docs/README.zh-CN.md",
13
+ "docs/README.ja.md",
12
14
  "CHANGELOG.md",
13
15
  "THIRD_PARTY_NOTICES.md",
14
16
  "LICENSE",
15
- "examples/vue-cms.vue"
17
+ "examples/vue-client.vue",
18
+ "examples/react-client.tsx"
16
19
  ],
17
20
  "sideEffects": [
18
21
  "**/*.css"
package/docs/go-vue.en.md DELETED
@@ -1,256 +0,0 @@
1
- # Go + Vue: login, binding and confirmed publication
2
-
3
- **English** | [简体中文](./go-vue.zh-CN.md) | [日本語](./go-vue.ja.md) · [SDK reference](./README.en.md)
4
-
5
- This walkthrough follows a working Go CMS and Vue 3 integration. Example origins replace local deployment details; no test users, passwords or tenant credentials are required by these examples. Your website implements login, persistence and business routes. The web package displays and observes requests; the Go SDK calls AuthLink and validates receipts.
6
-
7
- ## 1. Install and separate the three origins
8
-
9
- | Role | Example origin | Configuration |
10
- | --- | --- | --- |
11
- | Vue | `https://cms.example.com` | `apiOrigin` is the Go backend below |
12
- | Go CMS API | `https://cms-api.example.com` | `baseUrl=https://cms-api.example.com/api/authlink` |
13
- | AuthLink API | `https://api.getauthlink.com` | Go SDK `Origin`; browser `authLinkOrigin` |
14
-
15
- The AuthLink API uses production origin `https://api.getauthlink.com`; the Vue and Go CMS origins remain examples for your own website, not the AuthLink marketing site.
16
-
17
- Register the Vue site's origin for the tenant. The Go SDK `Origin` and the API origin in an exported credential JSON identify the AuthLink API. Browser `baseUrl` targets your Go application API. Keep TenantID/APIKey in private backend configuration; public runtime configuration contains origins only.
18
-
19
- ```sh
20
- npm install @auth-link/web@latest vue
21
- # In your Go backend module, after unpacking the supplied Go SDK:
22
- go mod edit -replace=authlink.local/sdk=./third_party/authlink-go
23
- go mod edit -require=authlink.local/sdk@v0.0.0
24
- go mod tidy
25
- ```
26
-
27
- 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`.
28
-
29
- ## 2. Log in to the website and load its session
30
-
31
- 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.
32
-
33
- 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.
34
-
35
- 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.
36
-
37
- ## 3. Configure Go CORS and application routes
38
-
39
- ```http
40
- Access-Control-Allow-Origin: https://cms.example.com
41
- Access-Control-Allow-Credentials: true
42
- Access-Control-Allow-Methods: GET, POST, PUT, OPTIONS
43
- Access-Control-Allow-Headers: Content-Type, X-CSRF-Token, Idempotency-Key
44
- Vary: Origin
45
- Cache-Control: no-store
46
- ```
47
-
48
- Return these headers only for an exact allowed Vue origin, including allowed-origin error responses. Validate the origin/method/headers of OPTIONS and return 204 before requiring login or CSRF; authenticate and check CSRF/ownership on actual requests. Do not reflect arbitrary origins or use `*`. PUT is for CMS draft editing; narrow it to the routes you implement.
49
-
50
- | Application route | Input | Output |
51
- | --- | --- | --- |
52
- | `GET /api/session` | — | `{authenticated:true, csrf:"…", subject:"…", username:"…"}`; logged out: `{authenticated:false}` |
53
- | `POST /api/authlink/requests` | `{purpose:"bind"}` or `{purpose:"confirm",postId,version}`; `Idempotency-Key` | Public `Ticket` |
54
- | `POST /api/authlink/requests/{id}/result` | `{}`; current session + CSRF | Pending status/displayCode or verified `confirmed` receipt |
55
- | `POST /api/posts/{id}/publish` | `{requestId,version}`; current session + CSRF | `{ok:true}` after checking the saved confirmation and intent |
56
-
57
- Your Go application implements all four routes. postId/version and the publication route are application-specific conventions, not AuthLink fields.
58
-
59
- ## 4. Persist before creation; commit verification before confirmed
60
-
61
- ```text
62
- Vue → Go /api/session → current session + csrf
63
- Vue → Go /api/authlink/requests → Go SDK CreateRequest → public ticket
64
- Vue dialog → Go /api/authlink/requests/{id}/result → Status + Consume
65
- Go commits verified receipt → Vue confirmed event
66
- Vue → Go /api/posts/{id}/publish → validate saved intent/receipt → publish
67
- ```
68
-
69
- Derive the stable user ID from the authenticated session. Before an upstream call, validate purpose/idempotency and optional post ownership/version; independently generate state and a consume key, then persist CreateInput, the browser creation key, session/user and publication intent. A network retry loads the same record and keys. Enforce unique (session, creation key) and reject reuse for a different purpose/post/version.
70
-
71
- Save the returned ticket, requestId and original expiresAt; omit optional qrSvg before returning public data. The result route locks the request owned by the current session and reuses any saved receipt. Call Status, then Consume only for approved/consumed using Expected reconstructed from saved data. Persist the verified public receipt, session confirmation and audit transactionally with unique requestId/verificationId constraints; commit before returning confirmed. Pending results preserve displayCode without caching beyond its validity.
72
-
73
- This is the SDK-call layer, **not a complete HTTP server**. Your application must implement the database/transaction and ownership steps described above and in the comments.
74
-
75
- ```go
76
- package cmsauth
77
-
78
- import (
79
- "context"
80
- authlink "authlink.local/sdk"
81
- )
82
-
83
- // Load this record from your database for the authenticated user/session.
84
- // Save Input (including State), CreateKey and ConsumeKey BEFORE calling Create.
85
- // Intent (post ID/version), ownership and unique constraints belong in your DB.
86
- type StoredRequest struct {
87
- Input authlink.CreateInput
88
- CreateKey string
89
- ConsumeKey string
90
- Ticket authlink.Ticket
91
- }
92
-
93
- // On network retries, load the SAME record; do not regenerate State or keys.
94
- func Create(ctx context.Context, sdk *authlink.Client, saved StoredRequest) (authlink.Ticket, error) {
95
- return sdk.CreateRequest(ctx, saved.Input, saved.CreateKey)
96
- }
97
-
98
- type PublicConfirmation struct {
99
- Status string `json:"status"`
100
- RequestID string `json:"requestId"`
101
- VerificationID string `json:"verificationId"`
102
- Purpose string `json:"purpose"`
103
- ExpiresAt string `json:"expiresAt"`
104
- AuthenticationPolicy string `json:"authenticationPolicy"`
105
- }
106
-
107
- // Caller checks session/CSRF/ownership, locks the saved record and first returns
108
- // any already-saved receipt. For a new confirmed result, caller must persist
109
- // the receipt and confirmation in a DB transaction BEFORE returning HTTP 200.
110
- func Poll(ctx context.Context, sdk *authlink.Client, saved StoredRequest) (any, error) {
111
- status, err := sdk.Status(ctx, saved.Ticket.RequestID)
112
- if err != nil { return nil, err }
113
- if status.Status != "approved" && status.Status != "consumed" { return status, nil }
114
- result, err := sdk.Consume(ctx, authlink.Expected{
115
- RequestID: saved.Ticket.RequestID,
116
- ExternalUserID: saved.Input.ExternalUserID,
117
- Purpose: saved.Input.Purpose,
118
- State: saved.Input.State,
119
- ExpiresAt: saved.Ticket.ExpiresAt,
120
- }, saved.ConsumeKey)
121
- if err != nil { return nil, err }
122
- return PublicConfirmation{
123
- Status: "confirmed", RequestID: result.RequestID,
124
- VerificationID: result.VerificationID, Purpose: result.Purpose,
125
- ExpiresAt: saved.Ticket.ExpiresAt, AuthenticationPolicy: result.AuthenticationPolicy,
126
- }, nil
127
- }
128
- ```
129
-
130
- ## 5. Vue lifecycle and business-bound requests
131
-
132
- The complete file is also shipped as [examples/vue-cms.vue](../examples/vue-cms.vue). Mount after site login with trusted apiOrigin/authLinkOrigin and an optional saved draft `post={id,version}`. Key the parent component by the current authenticated session identity. Unmount on logout, login rotation or account change; clear old tickets, creation keys and pending publication before mounting a new instance.
133
-
134
- ```vue
135
- <script setup lang="ts">
136
- import { markRaw, onMounted, onUnmounted, ref, shallowRef } from 'vue';
137
- import { createAuthLinkClient, requestKey, validateTicket } from '@auth-link/web';
138
- import type { Confirmation, Mode, Ticket } from '@auth-link/web';
139
- import { AuthLinkBind, AuthLinkConfirm } from '@auth-link/web/vue';
140
- import '@auth-link/web/styles.css';
141
-
142
- type Post = { id: string; version: number };
143
- type Publication = Post & { requestId: string };
144
- // Supply trusted deployment configuration. Mount after site login and remount
145
- // with :key="session.subject" after login, logout or account/session changes.
146
- const props = defineProps<{ apiOrigin: string; authLinkOrigin: string; post?: Post }>();
147
- const session = ref<{ authenticated: boolean; csrf?: string } | null>(null);
148
- const ticket = shallowRef<Ticket | null>(null);
149
- const publication = shallowRef<Post | null>(null);
150
- const retryPublish = shallowRef<Publication | null>(null);
151
- const busy = ref(false), message = ref('');
152
- let creation: { signature: string; key: string } | null = null;
153
- const lifetime = new AbortController();
154
- const client = markRaw(createAuthLinkClient({
155
- baseUrl: `${props.apiOrigin}/api/authlink`,
156
- allowedBackendOrigins: [props.apiOrigin],
157
- credentials: 'include',
158
- csrfToken: () => session.value?.csrf ?? '',
159
- authLinkOrigin: props.authLinkOrigin,
160
- }));
161
-
162
- async function api(path: string, body?: unknown, headers: Record<string, string> = {}) {
163
- const response = await fetch(`${props.apiOrigin}${path}`, {
164
- method: body === undefined ? 'GET' : 'POST',
165
- credentials: 'include', cache: 'no-store', redirect: 'error',
166
- signal: lifetime.signal,
167
- headers: body === undefined ? {} : {
168
- 'Content-Type': 'application/json', 'X-CSRF-Token': session.value?.csrf ?? '', ...headers,
169
- },
170
- body: body === undefined ? undefined : JSON.stringify(body),
171
- });
172
- const value = await response.json();
173
- if (response.status === 401) {
174
- session.value = null; closeTicket(); retryPublish.value = null; creation = null;
175
- }
176
- if (!response.ok) throw new Error(value.code ?? 'REQUEST_FAILED');
177
- return value;
178
- }
179
- function closeTicket() { ticket.value = null; publication.value = null; }
180
- function showError(error: Error) { message.value = error.message; }
181
- function finish(text: string) { closeTicket(); message.value = text; }
182
-
183
- async function start(purpose: Mode, post?: Post) {
184
- if (busy.value || ticket.value || !session.value?.authenticated) return;
185
- busy.value = true; message.value = ''; retryPublish.value = null;
186
- const intent = post ? { id: post.id, version: post.version } : null;
187
- const signature = JSON.stringify({ purpose, postId: intent?.id, version: intent?.version });
188
- try {
189
- if (creation?.signature !== signature) creation = { signature, key: requestKey() };
190
- // SDK create() sends only {purpose}. Business fields use your own API.
191
- const value = intent
192
- ? validateTicket(await api('/api/authlink/requests', {
193
- purpose, postId: intent.id, version: intent.version,
194
- }, { 'Idempotency-Key': creation.key }), purpose, props.authLinkOrigin)
195
- : await client.create(purpose, { idempotencyKey: creation.key, signal: lifetime.signal });
196
- if (lifetime.signal.aborted) return;
197
- publication.value = intent; ticket.value = value; creation = null;
198
- } catch (error) {
199
- if (!lifetime.signal.aborted) showError(error as Error);
200
- // Retain creation.key for a retry of the SAME purpose/post/version.
201
- } finally { busy.value = false; }
202
- }
203
- async function publish() {
204
- const intent = retryPublish.value;
205
- if (!intent || busy.value) return;
206
- busy.value = true;
207
- try {
208
- await api(`/api/posts/${intent.id}/publish`, { version: intent.version, requestId: intent.requestId });
209
- retryPublish.value = null; message.value = 'Published. Reload your posts from the backend.';
210
- } catch (error) { if (!lifetime.signal.aborted) showError(error as Error); }
211
- finally { busy.value = false; }
212
- }
213
- async function confirmed(result: Confirmation) {
214
- const intent = publication.value;
215
- closeTicket();
216
- if (intent) { retryPublish.value = { ...intent, requestId: result.requestId }; await publish(); }
217
- else message.value = 'Verified. Reload account status from the backend.';
218
- }
219
- onMounted(async () => {
220
- try {
221
- const value = await api('/api/session');
222
- if (value.authenticated !== true || typeof value.csrf !== 'string' || !value.csrf) {
223
- throw new Error('LOGIN_REQUIRED');
224
- }
225
- session.value = value;
226
- } catch (error) { if (!lifetime.signal.aborted) showError(error as Error); }
227
- });
228
- onUnmounted(() => { lifetime.abort(); closeTicket(); creation = null; retryPublish.value = null; });
229
- </script>
230
-
231
- <template>
232
- <button :disabled="busy || !!ticket || !session?.authenticated" @click="start('bind')">Bind phone</button>
233
- <button :disabled="busy || !!ticket || !session?.authenticated" @click="start('confirm')">Confirm</button>
234
- <button v-if="post" :disabled="busy || !!ticket || !session?.authenticated" @click="start('confirm', post)">Confirm publication</button>
235
- <button v-if="retryPublish" :disabled="busy || !!ticket" @click="publish">Retry the same publication</button>
236
- <AuthLinkBind v-if="ticket?.purpose === 'bind'" :ticket="ticket" :client="client" variant="B"
237
- @confirmed="confirmed" @close="finish('Closed')" @rejected="finish('Rejected')"
238
- @expired="finish('Expired; start a new request')" @error="showError" />
239
- <AuthLinkConfirm v-else-if="ticket?.purpose === 'confirm'" :ticket="ticket" :client="client" variant="B"
240
- @confirmed="confirmed" @close="finish('Closed')" @rejected="finish('Rejected')"
241
- @expired="finish('Expired; start a new request')" @error="showError" />
242
- <p role="status">{{ message }}</p>
243
- </template>
244
- ```
245
-
246
- `client.create(purpose, {idempotencyKey})` sends purpose only. To bind confirmation to a post/version, use your own fetch with those business fields, validate the result with `validateTicket(response, 'confirm', authLinkOrigin)`, and reuse the official client for observation. Keep the same creation key after an uncertain failure, and change it when purpose/post/version changes. Keep tickets in shallowRef and clients in markRaw.
247
-
248
- 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.
249
-
250
- ## 6. Recheck confirmation in the publish transaction
251
-
252
- 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.
253
-
254
- ## Acceptance sequence
255
-
256
- Site login → session bootstrap → backend AccountStatus (the reference CMS exposes it through overview) → bind if unbound → phone approval → consume and refresh account status → save draft → confirm that post/version → phone approval → commit receipt → publish → verify duplicate retry, wrong version, rejection and expiry. Real phone approval remains a separate acceptance step; compilation and mock HTTP checks do not replace it.
package/docs/go-vue.ja.md DELETED
@@ -1,256 +0,0 @@
1
- # Go + Vue:ログイン、端末登録、確認後の公開
2
-
3
- [English](./go-vue.en.md) | [简体中文](./go-vue.zh-CN.md) | **日本語** · [SDK リファレンス](./README.ja.md)
4
-
5
- 実際の Go CMS + Vue 3 の接続順序を示します。例のドメインは置き換え用で、ローカル設定・テスト用パスワード・テナント資格情報は含めません。ログイン、DB、業務 API はサイト側で実装し、Web SDK は表示と監視、Go SDK は AuthLink 呼び出しと応答検証を担当します。
6
-
7
- ## 1. 3 つの origin と SDK
8
-
9
- | 役割 | 例 | 設定 |
10
- | --- | --- | --- |
11
- | Vue | `https://cms.example.com` | `apiOrigin` は下記 Go バックエンド |
12
- | Go CMS API | `https://cms-api.example.com` | `baseUrl=https://cms-api.example.com/api/authlink` |
13
- | AuthLink API | `https://api.getauthlink.com` | Go SDK の `Origin`;ブラウザーの `authLinkOrigin` |
14
-
15
- AuthLink API は本番の `https://api.getauthlink.com` を使います。Vue と Go CMS の origin は接続先サイト自身の例であり、AuthLink 公式サイトのドメインへ置き換えません。
16
-
17
- テナントに登録するサイト origin は Vue の origin です。Go SDK の Origin と資格情報 JSON の API origin は AuthLink API を指します。ブラウザーの baseUrl は Go の業務 API です。TenantID/APIKey はバックエンドの非公開設定にのみ保存します。
18
-
19
- ```sh
20
- npm install @auth-link/web@latest vue
21
- # In your Go backend module, after unpacking the supplied Go SDK:
22
- go mod edit -replace=authlink.local/sdk=./third_party/authlink-go
23
- go mod edit -require=authlink.local/sdk@v0.0.0
24
- go mod tidy
25
- ```
26
-
27
- 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})` で初期化します。
28
-
29
- ## 2. サイトのログインと session
30
-
31
- サイトのログイン API(参照 CMS は POST /api/login)が Secure・HttpOnly の session Cookie を発行します。Vue は credentials=include で GET /api/session を読み、authenticated=true と csrf を確認してから要求を作成します。未ログインならサイト自身のログインへ戻します。
32
-
33
- この CMS の JSON フィールド名は **csrf**、SDK オプション名は **csrfToken** です。`csrfToken: () => session.value?.csrf ?? ''` で対応します。JSON の名前を SDK に合わせる必要はありません。Go が session ごとに生成・保存し、作成と結果照会の両方で X-CSRF-Token を検証します。テナントキーや照合数字を代用しません。
34
-
35
- 同じサイトの HTTPS サブドメイン同士は別 origin・同 site です。参照 API の Cookie は Domain を指定しない host-only、SameSite=Strict; Secure; HttpOnly; Path=/ です。別 site の場合は CORS に加えてブラウザーの Cookie 制限も考慮します。
36
-
37
- ## 3. Go の CORS と業務ルート
38
-
39
- ```http
40
- Access-Control-Allow-Origin: https://cms.example.com
41
- Access-Control-Allow-Credentials: true
42
- Access-Control-Allow-Methods: GET, POST, PUT, OPTIONS
43
- Access-Control-Allow-Headers: Content-Type, X-CSRF-Token, Idempotency-Key
44
- Vary: Origin
45
- Cache-Control: no-store
46
- ```
47
-
48
- 正確に許可した Vue origin にだけ返し、その origin のエラー応答にも付与します。OPTIONS は origin・method・headers を検証して 204 を返し、ログインや CSRF は要求しません。実リクエストでログイン、CSRF、所有者を検証します。任意 origin の反射や * は使いません。PUT は CMS の下書き編集用なので必要なルートに合わせて限定できます。
49
-
50
- | サイト自身のルート | 入力 | 出力 |
51
- | --- | --- | --- |
52
- | `GET /api/session` | — | `{authenticated:true, csrf:"…", subject:"…", username:"…"}`;未ログイン: `{authenticated:false}` |
53
- | `POST /api/authlink/requests` | `{purpose:"bind"}` または `{purpose:"confirm",postId,version}`; `Idempotency-Key` | 公開 `Ticket` |
54
- | `POST /api/authlink/requests/{id}/result` | `{}`;現在の session + CSRF | 待機状態/displayCode または検証済み `confirmed` |
55
- | `POST /api/posts/{id}/publish` | `{requestId,version}`; 現在の session + CSRF | `{ok:true}` 保存済み確認結果と業務意図の検証後 |
56
-
57
- これらのルートは Go CMS が実装します。postId/version と公開ルートはこの業務の契約で、AuthLink のプロトコル項目ではありません。
58
-
59
- ## 4. 作成前に保存し、検証のコミット後に confirmed
60
-
61
- ```text
62
- Vue → Go /api/session → current session + csrf
63
- Vue → Go /api/authlink/requests → Go SDK CreateRequest → public ticket
64
- Vue dialog → Go /api/authlink/requests/{id}/result → Status + Consume
65
- Go commits verified receipt → Vue confirmed event
66
- Vue → Go /api/posts/{id}/publish → validate saved intent/receipt → publish
67
- ```
68
-
69
- 安定したユーザー ID は認証済み session から取得します。用途、作成キー、記事の所有者と版を検証し、state と消費キーを別々に生成します。上流通信の前に CreateInput、ブラウザーの作成キー、ユーザー/session、記事公開の意図を保存します。通信失敗時は同一レコード・入力・キーを再利用します。session + 作成キーを一意にし、異なる用途・記事・版への再利用を拒否します。
70
-
71
- ticket、requestId、元の expiresAt を保存し、任意の qrSvg を除いて公開 ticket を返します。結果 API は現在の session が所有する記録をロックし、保存済み確認結果があれば再利用します。Status が approved/consumed のときだけ、DB の値から Expected を組み立て Consume を実行します。検証済み確認結果、session の確認記録、監査を requestId/verificationId の一意制約付きトランザクションで保存し、コミット後に confirmed を返します。待機中は displayCode を有効期限を越えてキャッシュしません。
72
-
73
- 以下は SDK 呼び出し層であり、完全な HTTP サーバーではありません。DB、トランザクション、所有者検証は説明とコメントに従ってサイト側で実装します。
74
-
75
- ```go
76
- package cmsauth
77
-
78
- import (
79
- "context"
80
- authlink "authlink.local/sdk"
81
- )
82
-
83
- // Load this record from your database for the authenticated user/session.
84
- // Save Input (including State), CreateKey and ConsumeKey BEFORE calling Create.
85
- // Intent (post ID/version), ownership and unique constraints belong in your DB.
86
- type StoredRequest struct {
87
- Input authlink.CreateInput
88
- CreateKey string
89
- ConsumeKey string
90
- Ticket authlink.Ticket
91
- }
92
-
93
- // On network retries, load the SAME record; do not regenerate State or keys.
94
- func Create(ctx context.Context, sdk *authlink.Client, saved StoredRequest) (authlink.Ticket, error) {
95
- return sdk.CreateRequest(ctx, saved.Input, saved.CreateKey)
96
- }
97
-
98
- type PublicConfirmation struct {
99
- Status string `json:"status"`
100
- RequestID string `json:"requestId"`
101
- VerificationID string `json:"verificationId"`
102
- Purpose string `json:"purpose"`
103
- ExpiresAt string `json:"expiresAt"`
104
- AuthenticationPolicy string `json:"authenticationPolicy"`
105
- }
106
-
107
- // Caller checks session/CSRF/ownership, locks the saved record and first returns
108
- // any already-saved receipt. For a new confirmed result, caller must persist
109
- // the receipt and confirmation in a DB transaction BEFORE returning HTTP 200.
110
- func Poll(ctx context.Context, sdk *authlink.Client, saved StoredRequest) (any, error) {
111
- status, err := sdk.Status(ctx, saved.Ticket.RequestID)
112
- if err != nil { return nil, err }
113
- if status.Status != "approved" && status.Status != "consumed" { return status, nil }
114
- result, err := sdk.Consume(ctx, authlink.Expected{
115
- RequestID: saved.Ticket.RequestID,
116
- ExternalUserID: saved.Input.ExternalUserID,
117
- Purpose: saved.Input.Purpose,
118
- State: saved.Input.State,
119
- ExpiresAt: saved.Ticket.ExpiresAt,
120
- }, saved.ConsumeKey)
121
- if err != nil { return nil, err }
122
- return PublicConfirmation{
123
- Status: "confirmed", RequestID: result.RequestID,
124
- VerificationID: result.VerificationID, Purpose: result.Purpose,
125
- ExpiresAt: saved.Ticket.ExpiresAt, AuthenticationPolicy: result.AuthenticationPolicy,
126
- }, nil
127
- }
128
- ```
129
-
130
- ## 5. Vue のライフサイクルと業務データ付き要求
131
-
132
- 完全なファイルは [examples/vue-cms.vue](../examples/vue-cms.vue) にも同梱します。サイトのログイン後、信頼できる apiOrigin/authLinkOrigin と任意の保存済み下書き `post={id,version}` を渡してマウントします。親は認証 session の識別子を key に使います。ログアウト・再ログイン・アカウント切り替えでは先にアンマウントし、古い ticket・作成キー・未完了の公開処理を消去してから新規マウントします。
133
-
134
- ```vue
135
- <script setup lang="ts">
136
- import { markRaw, onMounted, onUnmounted, ref, shallowRef } from 'vue';
137
- import { createAuthLinkClient, requestKey, validateTicket } from '@auth-link/web';
138
- import type { Confirmation, Mode, Ticket } from '@auth-link/web';
139
- import { AuthLinkBind, AuthLinkConfirm } from '@auth-link/web/vue';
140
- import '@auth-link/web/styles.css';
141
-
142
- type Post = { id: string; version: number };
143
- type Publication = Post & { requestId: string };
144
- // Supply trusted deployment configuration. Mount after site login and remount
145
- // with :key="session.subject" after login, logout or account/session changes.
146
- const props = defineProps<{ apiOrigin: string; authLinkOrigin: string; post?: Post }>();
147
- const session = ref<{ authenticated: boolean; csrf?: string } | null>(null);
148
- const ticket = shallowRef<Ticket | null>(null);
149
- const publication = shallowRef<Post | null>(null);
150
- const retryPublish = shallowRef<Publication | null>(null);
151
- const busy = ref(false), message = ref('');
152
- let creation: { signature: string; key: string } | null = null;
153
- const lifetime = new AbortController();
154
- const client = markRaw(createAuthLinkClient({
155
- baseUrl: `${props.apiOrigin}/api/authlink`,
156
- allowedBackendOrigins: [props.apiOrigin],
157
- credentials: 'include',
158
- csrfToken: () => session.value?.csrf ?? '',
159
- authLinkOrigin: props.authLinkOrigin,
160
- }));
161
-
162
- async function api(path: string, body?: unknown, headers: Record<string, string> = {}) {
163
- const response = await fetch(`${props.apiOrigin}${path}`, {
164
- method: body === undefined ? 'GET' : 'POST',
165
- credentials: 'include', cache: 'no-store', redirect: 'error',
166
- signal: lifetime.signal,
167
- headers: body === undefined ? {} : {
168
- 'Content-Type': 'application/json', 'X-CSRF-Token': session.value?.csrf ?? '', ...headers,
169
- },
170
- body: body === undefined ? undefined : JSON.stringify(body),
171
- });
172
- const value = await response.json();
173
- if (response.status === 401) {
174
- session.value = null; closeTicket(); retryPublish.value = null; creation = null;
175
- }
176
- if (!response.ok) throw new Error(value.code ?? 'REQUEST_FAILED');
177
- return value;
178
- }
179
- function closeTicket() { ticket.value = null; publication.value = null; }
180
- function showError(error: Error) { message.value = error.message; }
181
- function finish(text: string) { closeTicket(); message.value = text; }
182
-
183
- async function start(purpose: Mode, post?: Post) {
184
- if (busy.value || ticket.value || !session.value?.authenticated) return;
185
- busy.value = true; message.value = ''; retryPublish.value = null;
186
- const intent = post ? { id: post.id, version: post.version } : null;
187
- const signature = JSON.stringify({ purpose, postId: intent?.id, version: intent?.version });
188
- try {
189
- if (creation?.signature !== signature) creation = { signature, key: requestKey() };
190
- // SDK create() sends only {purpose}. Business fields use your own API.
191
- const value = intent
192
- ? validateTicket(await api('/api/authlink/requests', {
193
- purpose, postId: intent.id, version: intent.version,
194
- }, { 'Idempotency-Key': creation.key }), purpose, props.authLinkOrigin)
195
- : await client.create(purpose, { idempotencyKey: creation.key, signal: lifetime.signal });
196
- if (lifetime.signal.aborted) return;
197
- publication.value = intent; ticket.value = value; creation = null;
198
- } catch (error) {
199
- if (!lifetime.signal.aborted) showError(error as Error);
200
- // Retain creation.key for a retry of the SAME purpose/post/version.
201
- } finally { busy.value = false; }
202
- }
203
- async function publish() {
204
- const intent = retryPublish.value;
205
- if (!intent || busy.value) return;
206
- busy.value = true;
207
- try {
208
- await api(`/api/posts/${intent.id}/publish`, { version: intent.version, requestId: intent.requestId });
209
- retryPublish.value = null; message.value = 'Published. Reload your posts from the backend.';
210
- } catch (error) { if (!lifetime.signal.aborted) showError(error as Error); }
211
- finally { busy.value = false; }
212
- }
213
- async function confirmed(result: Confirmation) {
214
- const intent = publication.value;
215
- closeTicket();
216
- if (intent) { retryPublish.value = { ...intent, requestId: result.requestId }; await publish(); }
217
- else message.value = 'Verified. Reload account status from the backend.';
218
- }
219
- onMounted(async () => {
220
- try {
221
- const value = await api('/api/session');
222
- if (value.authenticated !== true || typeof value.csrf !== 'string' || !value.csrf) {
223
- throw new Error('LOGIN_REQUIRED');
224
- }
225
- session.value = value;
226
- } catch (error) { if (!lifetime.signal.aborted) showError(error as Error); }
227
- });
228
- onUnmounted(() => { lifetime.abort(); closeTicket(); creation = null; retryPublish.value = null; });
229
- </script>
230
-
231
- <template>
232
- <button :disabled="busy || !!ticket || !session?.authenticated" @click="start('bind')">Bind phone</button>
233
- <button :disabled="busy || !!ticket || !session?.authenticated" @click="start('confirm')">Confirm</button>
234
- <button v-if="post" :disabled="busy || !!ticket || !session?.authenticated" @click="start('confirm', post)">Confirm publication</button>
235
- <button v-if="retryPublish" :disabled="busy || !!ticket" @click="publish">Retry the same publication</button>
236
- <AuthLinkBind v-if="ticket?.purpose === 'bind'" :ticket="ticket" :client="client" variant="B"
237
- @confirmed="confirmed" @close="finish('Closed')" @rejected="finish('Rejected')"
238
- @expired="finish('Expired; start a new request')" @error="showError" />
239
- <AuthLinkConfirm v-else-if="ticket?.purpose === 'confirm'" :ticket="ticket" :client="client" variant="B"
240
- @confirmed="confirmed" @close="finish('Closed')" @rejected="finish('Rejected')"
241
- @expired="finish('Expired; start a new request')" @error="showError" />
242
- <p role="status">{{ message }}</p>
243
- </template>
244
- ```
245
-
246
- client.create が送るのは purpose のみです。postId/version が必要なら業務 API に fetch で送り、`validateTicket(response, 'confirm', authLinkOrigin)` で検証した後、監視には公式 client を使います。同じ作成の失敗ではキーを保持し、用途・記事・版が変わったときだけ新規キーにします。ticket は shallowRef、client は markRaw で保持します。
247
-
248
- マウント時は既存要求を監視し、mounted/watch で新規作成しません。閉じる・拒否・期限切れでは ticket を消去し、通信エラーでは同じ要求を再試行できるよう表示を維持します。confirmed 後は表示を閉じ、独立した公開 API を呼びます。失敗時は同じ requestId/記事/版で再試行し、実状態をバックエンドから再取得します。サイト自身のログインフォーム・記事一覧・エラー翻訳は例から省略しています。
249
-
250
- ## 6. 公開トランザクションで再検証
251
-
252
- ブラウザーの confirmed イベントは許可証ではありません。Go は現在の session の保存済み確認結果を読み、purpose=confirm、intent=publish、記事の所有者、同一 postId/version、下書き状態、初回適用時の有効期限を検証します。記事更新と applied の記録を一括コミットします。同じ適用済み操作の再試行は重複更新・監査なしで成功を返します。別記事や編集後の版には新たな確認が必要です。AuthLink 確認結果に action、transactionHash、記事本文を追加しません。
253
-
254
- ## 検証順序
255
-
256
- サイトログイン → session → バックエンド AccountStatus(参照 CMS は overview 経由)→ 未登録なら bind → スマートフォンで承認 → 消費と状態再取得 → 下書き保存 → 記事/版の confirm → 承認 → 確認結果保存 → 公開 → 再試行・異なる版・拒否・期限切れ。実端末での承認は利用者が行い、コンパイルや模擬 HTTP 検証とは区別します。