Skip to content

Commit 8756d26

Browse files
committed
docs
1 parent 77659a6 commit 8756d26

File tree

2 files changed

+596
-0
lines changed

2 files changed

+596
-0
lines changed

docs/OAUTH.md

Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
# OAuth 2.0 / OpenID Connect Provider
2+
3+
CoinPay implements an OAuth 2.0 Authorization Code flow with PKCE support and OpenID Connect (OIDC) compatibility. Third-party applications can authenticate CoinPay merchants and access their profile, email, wallet, and DID data with consent.
4+
5+
## Overview
6+
7+
| Feature | Details |
8+
|---|---|
9+
| Grant type | `authorization_code` (with optional PKCE) |
10+
| Token format | JWT (HS256) |
11+
| ID token | OIDC-compliant, issued when `openid` scope is requested |
12+
| Refresh tokens | 30-day expiry, single-use rotation |
13+
| Client secrets | Bcrypt-hashed at rest; shown once on creation |
14+
| Consent | Per-client, per-user; remembered across sessions |
15+
16+
## Base URL
17+
18+
```
19+
https://coinpayportal.com
20+
```
21+
22+
## Endpoints
23+
24+
| Endpoint | Method | Auth | Description |
25+
|---|---|---|---|
26+
| `/api/oauth/authorize` | GET | Cookie/Bearer | Authorization endpoint — redirects to login → consent → callback |
27+
| `/api/oauth/authorize` | POST | Cookie/Bearer | Consent approval/denial |
28+
| `/api/oauth/token` | POST | Client credentials | Exchange auth code or refresh token for access/ID tokens |
29+
| `/api/oauth/userinfo` | GET | Bearer (access token) | OIDC UserInfo — returns user claims |
30+
| `/api/oauth/jwks` | GET | Public | JWKS endpoint (HS256 key metadata) |
31+
| `/api/oauth/clients` | GET | Bearer | List your OAuth clients |
32+
| `/api/oauth/clients` | POST | Bearer | Register a new OAuth client |
33+
| `/api/oauth/clients/[id]` | GET/PATCH/DELETE | Bearer | Manage a specific client |
34+
| `/api/oauth/clients/lookup` | GET | Public | Look up client name/scopes by `client_id` |
35+
36+
## Scopes
37+
38+
| Scope | Description |
39+
|---|---|
40+
| `openid` | Verify identity (always included if any valid scope is present) |
41+
| `profile` | Access name and profile picture |
42+
| `email` | Access email address |
43+
| `did` | Access decentralized identifier |
44+
| `wallet:read` | View wallet addresses |
45+
46+
## Authorization Code Flow
47+
48+
### 1. Redirect user to authorize
49+
50+
```
51+
GET /api/oauth/authorize
52+
?response_type=code
53+
&client_id=cp_xxxxxxxxxxxx
54+
&redirect_uri=https://yourapp.com/callback
55+
&scope=openid+profile+email
56+
&state=<random-csrf-token>
57+
&code_challenge=<S256-challenge> # optional (PKCE)
58+
&code_challenge_method=S256 # optional (PKCE)
59+
&nonce=<random-nonce> # optional (OIDC)
60+
```
61+
62+
**What happens:**
63+
1. If the user is not logged in → redirected to `/login?redirect=...`
64+
2. If the user has already consented to the requested scopes → auth code issued immediately
65+
3. Otherwise → redirected to `/oauth/consent` to approve/deny
66+
67+
### 2. User approves consent
68+
69+
The consent page shows the requesting app name, description, and requested scopes. On approval, CoinPay redirects back to `redirect_uri`:
70+
71+
```
72+
https://yourapp.com/callback?code=<auth-code>&state=<state>
73+
```
74+
75+
On denial:
76+
77+
```
78+
https://yourapp.com/callback?error=access_denied&error_description=User+denied+the+request&state=<state>
79+
```
80+
81+
Auth codes expire after **10 minutes** and are single-use.
82+
83+
### 3. Exchange code for tokens
84+
85+
```http
86+
POST /api/oauth/token
87+
Content-Type: application/x-www-form-urlencoded
88+
89+
grant_type=authorization_code
90+
&code=<auth-code>
91+
&redirect_uri=https://yourapp.com/callback
92+
&client_id=cp_xxxxxxxxxxxx
93+
&client_secret=cps_xxxxxxxxxxxx # required for confidential clients
94+
&code_verifier=<pkce-verifier> # required if code_challenge was sent
95+
```
96+
97+
Client credentials can also be sent via `Authorization: Basic <base64(client_id:client_secret)>` header.
98+
99+
**Response:**
100+
101+
```json
102+
{
103+
"access_token": "eyJhbGciOiJIUzI1NiIs...",
104+
"token_type": "Bearer",
105+
"expires_in": 3600,
106+
"refresh_token": "a1b2c3d4...",
107+
"scope": "openid profile email",
108+
"id_token": "eyJhbGciOiJIUzI1NiIs..."
109+
}
110+
```
111+
112+
### 4. Fetch user info
113+
114+
```http
115+
GET /api/oauth/userinfo
116+
Authorization: Bearer <access_token>
117+
```
118+
119+
**Response:**
120+
121+
```json
122+
{
123+
"sub": "merchant-uuid",
124+
"name": "Alice",
125+
"email": "alice@example.com",
126+
"email_verified": true,
127+
"wallets": [
128+
{ "address": "bc1q...", "chain": "bitcoin", "label": "Main" }
129+
],
130+
"did": "did:web:example.com"
131+
}
132+
```
133+
134+
Fields returned depend on granted scopes.
135+
136+
### 5. Refresh tokens
137+
138+
```http
139+
POST /api/oauth/token
140+
Content-Type: application/x-www-form-urlencoded
141+
142+
grant_type=refresh_token
143+
&refresh_token=<refresh-token>
144+
&client_id=cp_xxxxxxxxxxxx
145+
&client_secret=cps_xxxxxxxxxxxx
146+
```
147+
148+
Refresh tokens are **single-use with rotation** — each exchange returns a new refresh token and revokes the old one. Refresh tokens expire after **30 days**.
149+
150+
## PKCE (Proof Key for Code Exchange)
151+
152+
Public clients (e.g., SPAs, mobile apps) should use PKCE instead of a client secret:
153+
154+
1. Generate a random `code_verifier` (43–128 chars, URL-safe)
155+
2. Compute `code_challenge = BASE64URL(SHA256(code_verifier))`
156+
3. Send `code_challenge` + `code_challenge_method=S256` in the authorize request
157+
4. Send `code_verifier` in the token exchange request
158+
159+
Supported methods: `S256` (recommended), `plain`.
160+
161+
## Client Registration
162+
163+
### Dashboard
164+
165+
Navigate to **Dashboard → OAuth** to create and manage clients via the UI.
166+
167+
### API
168+
169+
```http
170+
POST /api/oauth/clients
171+
Authorization: Bearer <session-token>
172+
Content-Type: application/json
173+
174+
{
175+
"name": "My App",
176+
"description": "Optional description",
177+
"redirect_uris": ["https://myapp.com/callback"],
178+
"scopes": ["openid", "profile", "email"]
179+
}
180+
```
181+
182+
**Response (201):**
183+
184+
```json
185+
{
186+
"success": true,
187+
"client": {
188+
"id": "uuid",
189+
"client_id": "cp_xxxxxxxxxxxx",
190+
"name": "My App",
191+
"redirect_uris": ["https://myapp.com/callback"],
192+
"scopes": ["openid", "profile", "email"],
193+
"client_secret": "cps_xxxxxxxxxxxx",
194+
"is_active": true
195+
},
196+
"warning": "Store the client_secret securely. It will not be shown again."
197+
}
198+
```
199+
200+
⚠️ The `client_secret` is shown **only once** at creation time. Store it securely.
201+
202+
### Client Management
203+
204+
```http
205+
# List clients
206+
GET /api/oauth/clients
207+
208+
# Get single client
209+
GET /api/oauth/clients/{id}
210+
211+
# Update client
212+
PATCH /api/oauth/clients/{id}
213+
{ "name": "Updated Name", "redirect_uris": [...], "is_active": false }
214+
215+
# Delete client
216+
DELETE /api/oauth/clients/{id}
217+
```
218+
219+
### Public Client Lookup
220+
221+
```http
222+
GET /api/oauth/clients/lookup?client_id=cp_xxxxxxxxxxxx
223+
```
224+
225+
Returns public info (name, description, scopes) — no secrets. Used by the consent page.
226+
227+
## JWKS
228+
229+
```http
230+
GET /api/oauth/jwks
231+
```
232+
233+
Returns key metadata. Since tokens use HS256 (symmetric signing), clients need the shared secret (`OIDC_SIGNING_SECRET` or `JWT_SECRET`) to verify tokens locally.
234+
235+
## ID Token Claims
236+
237+
| Claim | Scope | Description |
238+
|---|---|---|
239+
| `iss` | always | Issuer URL (`APP_URL`) |
240+
| `sub` | always | Merchant UUID |
241+
| `aud` | always | Client ID |
242+
| `iat` / `exp` | always | Issued-at / expiry (1 hour) |
243+
| `nonce` | always | Echoed if provided in authorize request |
244+
| `email` | `email` | Merchant email |
245+
| `email_verified` | `email` | Always `true` for existing merchants |
246+
| `name` | `profile` | Merchant display name |
247+
248+
## Error Responses
249+
250+
All error responses follow RFC 6749:
251+
252+
```json
253+
{
254+
"error": "invalid_grant",
255+
"error_description": "Authorization code has expired"
256+
}
257+
```
258+
259+
Common errors: `invalid_request`, `invalid_client`, `invalid_grant`, `unsupported_grant_type`, `invalid_scope`, `access_denied`, `login_required`.
260+
261+
## Environment Variables
262+
263+
| Variable | Required | Description |
264+
|---|---|---|
265+
| `JWT_SECRET` | Yes | Signing secret for JWTs (shared with regular auth) |
266+
| `OIDC_SIGNING_SECRET` | No | Override signing secret for OIDC tokens (falls back to `JWT_SECRET`) |
267+
| `NEXT_PUBLIC_APP_URL` | Yes | Public-facing URL (e.g., `https://coinpayportal.com`) |
268+
| `APP_URL` | No | Server-side override for public URL |
269+
| `NEXT_PUBLIC_SUPABASE_URL` | Yes | Supabase project URL |
270+
| `SUPABASE_SERVICE_ROLE_KEY` | Yes | Supabase service role key |
271+
272+
## Database Tables
273+
274+
- `oauth_clients` — registered OAuth applications
275+
- `oauth_authorization_codes` — pending/used auth codes
276+
- `oauth_refresh_tokens` — refresh tokens with revocation tracking
277+
- `oauth_consents` — user consent records (per client × user)
278+
279+
## Integration Example
280+
281+
Here's a complete example integrating CoinPay OAuth into a Next.js app:
282+
283+
```typescript
284+
// 1. Initiate OAuth flow
285+
// GET /api/auth/coinpay
286+
import { randomBytes, createHash } from 'crypto';
287+
288+
const state = randomBytes(32).toString('base64url');
289+
const codeVerifier = randomBytes(32).toString('base64url');
290+
const codeChallenge = createHash('sha256')
291+
.update(codeVerifier)
292+
.digest('base64url');
293+
294+
// Store state + codeVerifier in a secure cookie
295+
const authorizeUrl = new URL('https://coinpayportal.com/api/oauth/authorize');
296+
authorizeUrl.searchParams.set('response_type', 'code');
297+
authorizeUrl.searchParams.set('client_id', process.env.COINPAY_CLIENT_ID!);
298+
authorizeUrl.searchParams.set('redirect_uri', 'https://yourapp.com/api/callback/oauth');
299+
authorizeUrl.searchParams.set('scope', 'openid profile email');
300+
authorizeUrl.searchParams.set('state', state);
301+
authorizeUrl.searchParams.set('code_challenge', codeChallenge);
302+
authorizeUrl.searchParams.set('code_challenge_method', 'S256');
303+
304+
// Redirect user to authorizeUrl
305+
306+
// 2. Handle callback
307+
// GET /api/callback/oauth?code=xxx&state=xxx
308+
const tokenRes = await fetch('https://coinpayportal.com/api/oauth/token', {
309+
method: 'POST',
310+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
311+
body: new URLSearchParams({
312+
grant_type: 'authorization_code',
313+
code: searchParams.get('code')!,
314+
redirect_uri: 'https://yourapp.com/api/callback/oauth',
315+
client_id: process.env.COINPAY_CLIENT_ID!,
316+
code_verifier: storedCodeVerifier,
317+
}),
318+
});
319+
320+
const tokens = await tokenRes.json();
321+
322+
// 3. Fetch user profile
323+
const userRes = await fetch('https://coinpayportal.com/api/oauth/userinfo', {
324+
headers: { Authorization: `Bearer ${tokens.access_token}` },
325+
});
326+
const user = await userRes.json();
327+
// { sub: "uuid", name: "Alice", email: "alice@example.com", email_verified: true }
328+
```

0 commit comments

Comments
 (0)