Skip to content

Commit cfdd6ac

Browse files
Withings (#304)
Co-authored-by: pilcrowOnPaper <[email protected]>
1 parent 7577bf9 commit cfdd6ac

File tree

4 files changed

+175
-0
lines changed

4 files changed

+175
-0
lines changed

docs/malta.config.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
["Twitch", "/providers/twitch"],
7777
["Twitter", "/providers/twitter"],
7878
["VK", "/providers/vk"],
79+
["Withings", "/providers/withings"],
7980
["WorkOS", "/providers/workos"],
8081
["Yahoo", "/providers/yahoo"],
8182
["Yandex", "/providers/yandex"],

docs/pages/providers/withings.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
---
2+
title: "Withings"
3+
---
4+
5+
# Withings
6+
7+
OAuth 2.0 provider for Withings.
8+
9+
Also see the [OAuth 2.0](/guides/oauth2) guide.
10+
11+
## Initialization
12+
13+
```ts
14+
import * as arctic from "arctic";
15+
16+
const withings = new arctic.Withings(clientId, clientSecret, redirectURI);
17+
```
18+
19+
## Create authorization URL
20+
21+
```ts
22+
import * as arctic from "arctic";
23+
24+
const state = arctic.generateState();
25+
const scopes = ["user.info", "user.metrics", "user.activity"];
26+
const url = withings.createAuthorizationURL(state, scopes);
27+
```
28+
29+
## Validate authorization code
30+
31+
`validateAuthorizationCode()` will either return an [`OAuth2Tokens`](/reference/main/OAuth2Tokens), or throw one of [`OAuth2RequestError`](/reference/main/OAuth2RequestError), [`ArcticFetchError`](/reference/main/ArcticFetchError), [`UnexpectedResponseError`](/reference/main/UnexpectedResponseError), or [`UnexpectedErrorResponseBodyError`](/reference/main/UnexpectedErrorResponseBodyError). Withings will return an access token with an expiration.
32+
33+
Withings deviates from the RFC and the value of `OAuth2RequestError.code` will not be a registered OAuth 2.0 error code.
34+
35+
```ts
36+
import * as arctic from "arctic";
37+
38+
try {
39+
const tokens = await withings.validateAuthorizationCode(code);
40+
const accessToken = tokens.accessToken();
41+
const accessTokenExpiresAt = tokens.accessTokenExpiresAt();
42+
} catch (e) {
43+
if (e instanceof arctic.OAuth2RequestError) {
44+
// Invalid authorization code, credentials, or redirect URI
45+
const responseBody = e.code;
46+
// ...
47+
}
48+
if (e instanceof arctic.ArcticFetchError) {
49+
// Failed to call `fetch()`
50+
const cause = e.cause;
51+
// ...
52+
}
53+
// Parse error
54+
}
55+
```
56+
57+
## Get measures
58+
59+
Use the `/measure` endpoint. See [the API docs](https://developer.withings.com/api-reference/#tag/measure).
60+
61+
```ts
62+
const response = await fetch("https://wbsapi.withings.net/measure", {
63+
method: "POST",
64+
headers: {
65+
Authorization: `Bearer ${tokens.accessToken()}`,
66+
"Content-Type": "application/json"
67+
},
68+
body: JSON.stringify({
69+
action: "getmeas",
70+
meastypes: "1,5,6,8,76",
71+
category: 1,
72+
lastupdate: 1746082800
73+
})
74+
});
75+
const measures = await response.json();
76+
```

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export { Tumblr } from "./providers/tumblr.js";
5656
export { Twitch } from "./providers/twitch.js";
5757
export { Twitter } from "./providers/twitter.js";
5858
export { VK } from "./providers/vk.js";
59+
export { Withings } from "./providers/withings.js";
5960
export { WorkOS } from "./providers/workos.js";
6061
export { Yahoo } from "./providers/yahoo.js";
6162
export { Yandex } from "./providers/yandex.js";

src/providers/withings.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import {
2+
ArcticFetchError,
3+
createOAuth2Request,
4+
createOAuth2RequestError,
5+
UnexpectedErrorResponseBodyError,
6+
UnexpectedResponseError
7+
} from "../request.js";
8+
import { OAuth2Tokens } from "../oauth2.js";
9+
10+
const authorizationEndpoint = "https://account.withings.com/oauth2_user/authorize2";
11+
const tokenEndpoint = "https://wbsapi.withings.net/v2/oauth2";
12+
13+
export class Withings {
14+
private clientId: string;
15+
private clientSecret: string;
16+
private redirectURI: string;
17+
18+
constructor(clientId: string, clientSecret: string, redirectURI: string) {
19+
this.clientId = clientId;
20+
this.clientSecret = clientSecret;
21+
this.redirectURI = redirectURI;
22+
}
23+
24+
public createAuthorizationURL(state: string, scopes: string[]): URL {
25+
const url = new URL(authorizationEndpoint);
26+
url.searchParams.set("response_type", "code");
27+
url.searchParams.set("client_id", this.clientId);
28+
url.searchParams.set("state", state);
29+
// Withings deviates from the RFC and uses a comma-delimitated string instead of spaces.
30+
if (scopes.length > 0) {
31+
url.searchParams.set("scope", scopes.join(","));
32+
}
33+
url.searchParams.set("redirect_uri", this.redirectURI);
34+
return url;
35+
}
36+
37+
public async validateAuthorizationCode(code: string): Promise<OAuth2Tokens> {
38+
const body = new URLSearchParams();
39+
// Withings requires an `action` parameter.
40+
body.set("action", "requesttoken");
41+
body.set("grant_type", "authorization_code");
42+
body.set("code", code);
43+
body.set("redirect_uri", this.redirectURI);
44+
body.set("client_id", this.clientId);
45+
body.set("client_secret", this.clientSecret);
46+
const request = createOAuth2Request(tokenEndpoint, body);
47+
const tokens = await sendTokenRequest(request);
48+
return tokens;
49+
}
50+
}
51+
52+
async function sendTokenRequest(request: Request): Promise<OAuth2Tokens> {
53+
let response: Response;
54+
try {
55+
response = await fetch(request);
56+
} catch (e) {
57+
throw new ArcticFetchError(e);
58+
}
59+
60+
// Withings returns a 200 even for error responses.
61+
if (response.status !== 200) {
62+
if (response.body !== null) {
63+
await response.body.cancel();
64+
}
65+
throw new UnexpectedResponseError(response.status);
66+
}
67+
68+
let data: unknown;
69+
try {
70+
data = await response.json();
71+
} catch {
72+
throw new UnexpectedResponseError(response.status);
73+
}
74+
if (typeof data !== "object" || data === null) {
75+
throw new UnexpectedErrorResponseBodyError(response.status, data);
76+
}
77+
78+
// Withings returns an `error` field but the value deviates from the RFC.
79+
// Probably better to throw `UnexpectedErrorResponseBodyError`
80+
// but we're keeping `OAuth2RequestError` for now to be consistent with the other providers.
81+
if ("error" in data && typeof data.error === "string") {
82+
let error: Error;
83+
try {
84+
error = createOAuth2RequestError(data);
85+
} catch {
86+
throw new UnexpectedErrorResponseBodyError(response.status, data);
87+
}
88+
throw error;
89+
}
90+
91+
// Withings returns `{"status": 0, "body": {...}}`.
92+
if (!("body" in data) || typeof data.body !== "object" || data.body === null) {
93+
throw new Error("Missing or invalid 'body' field");
94+
}
95+
const tokens = new OAuth2Tokens(data.body);
96+
return tokens;
97+
}

0 commit comments

Comments
 (0)